Skip to content

Commit

Permalink
auth: Use standalone models for invitations
Browse files Browse the repository at this point in the history
- remove invite hacks from the social pipeline
- TODO: rewritten invitation to send invites directly
- invitations now work regardless registration open
- TODO: project admins can only invite outside users with registration
  open
- improved mail templates for invitations
- TODO: user profile view of pending invitations
- TODO: user has to accept the invitation to become team member

Fixes #9261
Fixes #9131
Fixes #7412
  • Loading branch information
nijel committed Jul 27, 2023
1 parent 8b514cb commit 8636998
Show file tree
Hide file tree
Showing 18 changed files with 382 additions and 72 deletions.
2 changes: 1 addition & 1 deletion docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Not yet released.

Please follow :ref:`generic-upgrade-instructions` in order to perform update.

* There are several changes in :file:`settings_example.py`, most notable is change in ``CACHES``, please adjust your settings accordingly.
* There are several changes in :file:`settings_example.py`, most notable is changes in ``CACHES`` and ``SOCIAL_AUTH_PIPELINE``, please adjust your settings accordingly.
* Several previously optional dependencies are now required.

`All changes in detail <https://github.com/WeblateOrg/weblate/milestone/99?closed=1>`__.
47 changes: 37 additions & 10 deletions weblate/accounts/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import annotations

import re
import time
import unicodedata
Expand All @@ -23,7 +25,8 @@
cycle_session_keys,
invalidate_reset_codes,
)
from weblate.auth.models import User
from weblate.auth.models import Invitation, User
from weblate.auth.views import accept_invitation
from weblate.trans.defines import FULLNAME_LENGTH
from weblate.utils import messages
from weblate.utils.ratelimit import reset_rate_limit
Expand Down Expand Up @@ -149,10 +152,6 @@ def send_validation(strategy, backend, code, partial_token):
template = "reset"
elif session.get("account_remove"):
template = "remove"
elif session.get("user_invite"):
template = "invite"
context.update(session["invitation_context"])
user = User.objects.get(pk=session["social_auth_user"])

# Create audit log, it might be for anonymous at this point for new registrations
AuditLog.objects.create(
Expand Down Expand Up @@ -196,7 +195,14 @@ def password_reset(

@partial
def remove_account(
strategy, backend, user, social, details, weblate_action, current_partial, **kwargs
strategy,
backend,
user,
social,
details,
weblate_action: str,
current_partial,
**kwargs,
):
"""Set unusable password on reset."""
if strategy.request is not None and user is not None and weblate_action == "remove":
Expand All @@ -213,12 +219,20 @@ def remove_account(
return None


def verify_open(strategy, backend, user, weblate_action, **kwargs):
def verify_open(
strategy,
backend,
user: User,
weblate_action: str,
invitation_link: Invitation | None,
**kwargs,
):
"""Check whether it is possible to create new user."""
# Check whether registration is open
if (
not user
and weblate_action not in ("reset", "remove", "invite")
and weblate_action not in ("reset", "remove")
and not invitation_link
and (not settings.REGISTRATION_OPEN or settings.REGISTRATION_ALLOW_BACKENDS)
and backend.name not in settings.REGISTRATION_ALLOW_BACKENDS
):
Expand Down Expand Up @@ -255,15 +269,21 @@ def store_params(strategy, user, **kwargs):
action = "reset"
elif session.get("account_remove"):
action = "remove"
elif session.get("user_invite"):
action = "invite"
else:
action = "activation"

invitation = None
if invitation_pk := session.get("invitation_link"):
try:
invitation = Invitation.objects.get(pk=invitation_pk)
except Invitation.DoesNotExist:
del session["invitation_link"]

return {
"weblate_action": action,
"registering_user": registering_user,
"weblate_expires": int(time.monotonic() + settings.AUTH_TOKEN_VALID),
"invitation_link": invitation,
}


Expand Down Expand Up @@ -391,6 +411,13 @@ def store_email(strategy, backend, user, social, details, **kwargs):
verified.save()


def handle_invite(
strategy, backend, user: User, social, invitation_link: Invitation | None, **kwargs
):
if invitation_link:
accept_invitation(invitation_link, user)

Check failure on line 418 in weblate/accounts/pipeline.py

View workflow job for this annotation

GitHub Actions / pylint

E1120: No value for argument 'user' in function call (no-value-for-parameter)


def notify_connect(
strategy,
details,
Expand Down
6 changes: 6 additions & 0 deletions weblate/accounts/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.urls import include, path

import weblate.accounts.views
import weblate.auth.views
from weblate.utils.urls import register_weblate_converters

register_weblate_converters()
Expand Down Expand Up @@ -72,6 +73,11 @@
path("login/", weblate.accounts.views.WeblateLoginView.as_view(), name="login"),
path("register/", weblate.accounts.views.register, name="register"),
path("email/", weblate.accounts.views.email_login, name="email_login"),
path(
"invitation/<uuid:pk>/",
weblate.auth.views.InvitationView.as_view(),
name="invitation",
),
path("", include((social_urls, "social_auth"), namespace="social")),
]

Expand Down
46 changes: 25 additions & 21 deletions weblate/accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
from weblate.accounts.pipeline import EmailAlreadyAssociated, UsernameAlreadyAssociated
from weblate.accounts.utils import remove_user
from weblate.auth.forms import UserEditForm
from weblate.auth.models import User, get_auth_keys
from weblate.auth.models import Invitation, User, get_auth_keys
from weblate.auth.utils import format_address
from weblate.logger import LOGGER
from weblate.trans.models import Change, Component, Suggestion, Translation
Expand Down Expand Up @@ -150,8 +150,6 @@ def get_context_data(self, **kwargs):
context["validity"] = settings.AUTH_TOKEN_VALID // 3600
context["is_reset"] = False
context["is_remove"] = False
# This view is not visible for invitation that's
# why don't handle user_invite here
if self.request.flags["password_reset"]:
context["title"] = gettext("Password reset")
context["is_reset"] = True
Expand All @@ -170,7 +168,6 @@ def get(self, request, *args, **kwargs):
request.flags = {
"password_reset": request.session["password_reset"],
"account_remove": request.session["account_remove"],
"user_invite": request.session["user_invite"],
}

# Remove session for not authenticated user here.
Expand Down Expand Up @@ -754,7 +751,6 @@ def fake_email_sent(request, reset=False):
request.session["registration-email-sent"] = True
request.session["password_reset"] = reset
request.session["account_remove"] = False
request.session["user_invite"] = False
return redirect("email-sent")


Expand All @@ -763,15 +759,29 @@ def register(request):
"""Registration form."""
captcha = None

if request.method == "POST":
# Fetch invitation
invitation = None
if invitation_pk := request.session.get("invitation_link"):
try:
invitation = Invitation.objects.get(pk=invitation_pk)
except Invitation.DoesNotExist:
del request.session["invitation_link"]

# Allow registration at all?
registration_open = settings.REGISTRATION_OPEN or bool(invitation)

# Get list of allowed backends
backends = get_auth_keys()
if settings.REGISTRATION_ALLOW_BACKENDS and not invitation:
backends = backends & set(settings.REGISTRATION_ALLOW_BACKENDS)
elif not registration_open:
backends = set()

if request.method == "POST" and "email" in backends:
form = RegistrationForm(request, request.POST)
if settings.REGISTRATION_CAPTCHA:
captcha = CaptchaForm(request, form, request.POST)
if (
(captcha is None or captcha.is_valid())
and form.is_valid()
and settings.REGISTRATION_OPEN
):
if (captcha is None or captcha.is_valid()) and form.is_valid():
if captcha:
captcha.cleanup_session(request)
if form.cleaned_data["email_user"]:
Expand All @@ -786,14 +796,8 @@ def register(request):
if settings.REGISTRATION_CAPTCHA:
captcha = CaptchaForm(request)

backends = get_auth_keys()
if settings.REGISTRATION_ALLOW_BACKENDS:
backends = backends & set(settings.REGISTRATION_ALLOW_BACKENDS)
elif not settings.REGISTRATION_OPEN:
backends = set()

# Redirect if there is only one backend
if len(backends) == 1 and "email" not in backends:
if len(backends) == 1 and "email" not in backends and not invitation:
return redirect_single(request, backends.pop())

return render(
Expand All @@ -805,6 +809,7 @@ def register(request):
"title": gettext("User registration"),
"form": form,
"captcha_form": captcha,
"invitation": invitation,
},
)

Expand Down Expand Up @@ -951,7 +956,7 @@ def reset_password(request):
form.cleaned_data["email_user"], request, "reset-request"
)
if not audit.check_rate_limit(request):
store_userid(request, True)
store_userid(request, reset=True)
return social_complete(request, "email")
else:
email = form.cleaned_data["email"]
Expand Down Expand Up @@ -1103,12 +1108,11 @@ def get_context_data(self, *, object_list=None, **kwargs):
return result


def store_userid(request, reset=False, remove=False, invite=False):
def store_userid(request, reset: bool = False, remove: bool = False):
"""Store user ID in the session."""
request.session["social_auth_user"] = request.user.pk
request.session["password_reset"] = reset
request.session["account_remove"] = remove
request.session["user_invite"] = invite


@require_POST
Expand Down
38 changes: 32 additions & 6 deletions weblate/auth/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@
from weblate.accounts.models import AuditLog
from weblate.accounts.strategy import create_session
from weblate.auth.data import GLOBAL_PERM_NAMES, SELECTION_MANUAL
from weblate.auth.models import Group, Role, User, get_anonymous
from weblate.auth.models import Group, Invitation, Role, User, get_anonymous
from weblate.trans.models import Change
from weblate.utils import messages
from weblate.utils.errors import report_error
from weblate.utils.forms import UserField


def send_invitation(request: HttpRequest, project_name: str, user: User):
Expand Down Expand Up @@ -56,14 +57,39 @@ def send_invitation(request: HttpRequest, project_name: str, user: User):
settings.AUTHENTICATION_BACKENDS = backup_backends


class InviteUserForm(forms.ModelForm, UniqueEmailMixin):
create = True
class InviteProjectMixin:
def __init__(
self,
data=None,
files=None,
project=None,
**kwargs,
):
self.project = project
super().__init__(
data=data,
files=files,
**kwargs,
)
if project:
self.fields["group"].queryset = project.group_set.all()
else:
self.fields["group"].queryset = Group.objects.filter(defining_project=None)


class InviteEmailForm(InviteProjectMixin, forms.ModelForm, UniqueEmailMixin):
class Meta:
model = User
fields = ["email", "username", "full_name"]
model = Invitation
fields = ["email", "group"]


class InviteUserForm(InviteProjectMixin, forms.ModelForm):
class Meta:
model = Invitation
fields = ["user", "group"]
field_classes = {"user": UserField}

def save(self, request, project=None):
def xsave(self, request, project=None):
self.instance.set_unusable_password()
user = super().save()
if project:
Expand Down
78 changes: 78 additions & 0 deletions weblate/auth/migrations/0028_invitation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Copyright © Michal Čihař <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later

# Generated by Django 4.2.3 on 2023-07-27 09:06

import uuid

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models

import weblate.utils.fields


class Migration(migrations.Migration):
dependencies = [
("weblate_auth", "0027_alter_group_components"),
]

operations = [
migrations.CreateModel(
name="Invitation",
fields=[
(
"uuid",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("timestamp", models.DateTimeField(auto_now_add=True)),
(
"email",
weblate.utils.fields.EmailField(
blank=True, max_length=190, verbose_name="E-mail"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="User has all possible permissions.",
verbose_name="Superuser status",
),
),
(
"group",
models.ForeignKey(
help_text="The user is granted all permissions included in membership of these groups.",
on_delete=django.db.models.deletion.CASCADE,
to="weblate_auth.group",
verbose_name="Group",
),
),
(
"user",
models.ForeignKey(
help_text="Please type in an existing Weblate account name or e-mail address.",
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
verbose_name="User to add",
),
),
(
"author",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="created_invitation_set",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]
Loading

0 comments on commit 8636998

Please sign in to comment.