Skip to content
Open
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ DEBUG=true
ALLOWED_HOSTS=localhost,127.0.0.1
APP_URL=http://localhost:8000

# Who may create an account: open | invite | closed
# open anyone (default)
# invite only visitors who followed a valid invitation link
# closed nobody; create accounts in the Django admin
# A self-hosted install has to be reachable from the internet for OAuth
# callbacks and webhooks, so "open" means open to the world.
SIGNUP_MODE=open

# DATABASE
DATABASE_URL=postgres://postgres:postgres@localhost:5432/brightbean

Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,38 @@ All platforms with ephemeral filesystems require `STORAGE_BACKEND=s3` - see `.en

See `architecture.md` for detailed per-platform instructions and cost breakdowns.

### Restricting Sign-up

A self-hosted install has to be reachable from the internet — that is where
the platforms deliver OAuth callbacks and webhooks — so by default anyone
who finds the domain can create an account. `SIGNUP_MODE` controls that:

| Value | Who may create an account |
|---|---|
| `open` (default) | Anyone. |
| `invite` | Only visitors who followed a valid invitation link. |
| `closed` | Nobody. Create accounts in the Django admin. |

```bash
SIGNUP_MODE=invite
```

Applies to both the email form and social logins — guarding only the form
would leave the provider callback open, since `SOCIALACCOUNT_AUTO_SIGNUP`
creates the account there without ever rendering the signup page.

**Signing in is never affected**, including for users who authenticate
through Google: an existing account is connected by email rather than
signed up.

In `invite` mode the invitation must be unaccepted and unexpired, so a
single link cannot be reused indefinitely. Invitations are sent from
**Settings → Members**.

An unrecognised value stops the app at startup rather than falling back to
a default — a typo that silently leaves sign-up open is the failure worth
being loud about.

## Project Structure

```
Expand Down
56 changes: 56 additions & 0 deletions apps/accounts/adapters.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,67 @@
from allauth.account.adapter import DefaultAccountAdapter
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from django.conf import settings

from apps.accounts.models import OAuthConnection


def pending_invitation(request):
"""The unaccepted, unexpired Invitation this visitor arrived with, if any.

The token is put into the session by the invitation link (see
apps.members.views) and consumed after signup by apps.accounts.signals.
"""
if request is None:
return None
session = getattr(request, "session", None)
if session is None:
return None
token = session.get("pending_invite_token")
if not token:
return None

from apps.members.models import Invitation

invitation = Invitation.objects.filter(token=token, accepted_at__isnull=True).first()
if invitation is None or invitation.is_expired:
return None
return invitation


def signup_allowed(request):
"""Whether a brand-new account may be created for this request.

Only ever asked when an account would actually be created. Signing in
with an existing account — including a social login that connects to an
existing user by email — does not pass through here.
"""
mode = getattr(settings, "SIGNUP_MODE", "open")
if mode == "open":
return True
if mode == "closed":
return False
return pending_invitation(request) is not None


class AccountAdapter(DefaultAccountAdapter):
"""Applies SIGNUP_MODE to the email/password signup form."""

def is_open_for_signup(self, request):
return signup_allowed(request)


class SocialAccountAdapter(DefaultSocialAccountAdapter):
"""Custom adapter that syncs Google social logins to OAuthConnection."""

def is_open_for_signup(self, request, sociallogin):
"""Applies SIGNUP_MODE to social signups.

Without this, closing the email form would achieve nothing:
SOCIALACCOUNT_AUTO_SIGNUP creates the account during the provider
callback, so a new Google user never visits the signup page at all.
"""
return signup_allowed(request)

def populate_user(self, request, sociallogin, data):
"""Set user.name from Google profile (custom User model has 'name', not first/last)."""
user = super().populate_user(request, sociallogin, data)
Expand Down
172 changes: 172 additions & 0 deletions apps/accounts/tests/test_signup_mode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
"""Tests for SIGNUP_MODE — the gate on creating new accounts.

The point of the setting is that an installation reachable from the
internet does not have to accept arbitrary sign-ups. Two things have to
hold for it to be worth anything:

* Both doors are covered. Closing the email form alone achieves nothing,
because SOCIALACCOUNT_AUTO_SIGNUP creates the account during the
provider callback without ever rendering the signup page.
* Existing users keep getting in. A gate that locks out the people it was
meant to protect is worse than no gate.
"""

import uuid
from datetime import timedelta

import pytest
from allauth.socialaccount.models import SocialAccount as AllAuthSocialAccount
from allauth.socialaccount.models import SocialLogin
from django.contrib.sessions.middleware import SessionMiddleware
from django.test import RequestFactory
from django.urls import reverse
from django.utils import timezone

from apps.accounts.adapters import AccountAdapter, SocialAccountAdapter
from apps.accounts.models import User
from apps.members.models import Invitation
from apps.organizations.models import Organization


def _request(session_data=None):
request = RequestFactory().get("/")
SessionMiddleware(lambda r: None).process_request(request)
for key, value in (session_data or {}).items():
request.session[key] = value
return request


@pytest.fixture
def account_adapter():
return AccountAdapter()


@pytest.fixture
def social_adapter():
return SocialAccountAdapter()


@pytest.fixture
def sociallogin():
account = AllAuthSocialAccount(provider="google", uid=f"uid-{uuid.uuid4()}")
return SocialLogin(user=User(email="new@example.com"), account=account)


@pytest.fixture
def invitation(db):
org = Organization.objects.create(name="Acme")
return Invitation.objects.create(
organization=org,
email="invited@example.com",
expires_at=timezone.now() + timedelta(days=7),
)


class TestOpenMode:
def test_email_signup_allowed(self, settings, account_adapter):
settings.SIGNUP_MODE = "open"
assert account_adapter.is_open_for_signup(_request()) is True

def test_social_signup_allowed(self, settings, social_adapter, sociallogin):
settings.SIGNUP_MODE = "open"
assert social_adapter.is_open_for_signup(_request(), sociallogin) is True


class TestClosedMode:
def test_email_signup_blocked(self, settings, account_adapter):
settings.SIGNUP_MODE = "closed"
assert account_adapter.is_open_for_signup(_request()) is False

def test_social_signup_blocked(self, settings, social_adapter, sociallogin):
"""The door that closing the form alone would leave wide open."""
settings.SIGNUP_MODE = "closed"
assert social_adapter.is_open_for_signup(_request(), sociallogin) is False

@pytest.mark.django_db
def test_an_invite_does_not_reopen_it(self, settings, account_adapter, invitation):
settings.SIGNUP_MODE = "closed"
request = _request({"pending_invite_token": invitation.token})
assert account_adapter.is_open_for_signup(request) is False


@pytest.mark.django_db
class TestInviteMode:
def test_blocked_without_invite(self, settings, account_adapter):
settings.SIGNUP_MODE = "invite"
assert account_adapter.is_open_for_signup(_request()) is False

def test_allowed_with_valid_invite(self, settings, account_adapter, invitation):
settings.SIGNUP_MODE = "invite"
request = _request({"pending_invite_token": invitation.token})
assert account_adapter.is_open_for_signup(request) is True

def test_social_allowed_with_valid_invite(self, settings, social_adapter, sociallogin, invitation):
settings.SIGNUP_MODE = "invite"
request = _request({"pending_invite_token": invitation.token})
assert social_adapter.is_open_for_signup(request, sociallogin) is True

def test_expired_invite_rejected(self, settings, account_adapter, invitation):
settings.SIGNUP_MODE = "invite"
invitation.expires_at = timezone.now() - timedelta(minutes=1)
invitation.save(update_fields=["expires_at"])
request = _request({"pending_invite_token": invitation.token})
assert account_adapter.is_open_for_signup(request) is False

def test_already_accepted_invite_rejected(self, settings, account_adapter, invitation):
"""Otherwise one invitation link would mint accounts indefinitely."""
settings.SIGNUP_MODE = "invite"
invitation.accepted_at = timezone.now()
invitation.save(update_fields=["accepted_at"])
request = _request({"pending_invite_token": invitation.token})
assert account_adapter.is_open_for_signup(request) is False

def test_unknown_token_rejected(self, settings, account_adapter):
settings.SIGNUP_MODE = "invite"
request = _request({"pending_invite_token": "not-a-real-token"})
assert account_adapter.is_open_for_signup(request) is False


@pytest.mark.django_db
class TestExistingUsersAreUnaffected:
def test_login_page_still_reachable_when_closed(self, settings, client):
settings.SIGNUP_MODE = "closed"
assert client.get(reverse("account_login")).status_code == 200

def test_existing_user_can_log_in_when_closed(self, settings, client):
settings.SIGNUP_MODE = "closed"
User.objects.create_user(email="existing@example.com", password="pw-for-test-only")

response = client.post(
reverse("account_login"),
{"login": "existing@example.com", "password": "pw-for-test-only"},
)

assert response.status_code == 302
assert response.wsgi_request.user.is_authenticated

def test_signup_page_shows_the_closed_notice(self, settings, client):
settings.SIGNUP_MODE = "closed"
response = client.get(reverse("account_signup"))
assert b"Sign-up is closed" in response.content


@pytest.mark.django_db
class TestLoginPageSignupLink:
def test_link_shown_when_open(self, settings, client):
settings.SIGNUP_MODE = "open"
assert b"Sign up" in client.get(reverse("account_login")).content

def test_link_hidden_when_closed(self, settings, client):
settings.SIGNUP_MODE = "closed"
assert b"Sign up" not in client.get(reverse("account_login")).content

def test_link_hidden_in_invite_mode_without_invite(self, settings, client):
settings.SIGNUP_MODE = "invite"
assert b"Sign up" not in client.get(reverse("account_login")).content

def test_link_shown_in_invite_mode_with_invite(self, settings, client, invitation):
settings.SIGNUP_MODE = "invite"
session = client.session
session["pending_invite_token"] = invitation.token
session.save()
assert b"Sign up" in client.get(reverse("account_login")).content
14 changes: 14 additions & 0 deletions apps/common/templatetags/common_extras.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@
register = template.Library()


@register.simple_tag(takes_context=True)
def signup_allowed(context):
"""Whether this visitor could sign up, per SIGNUP_MODE.

Used to hide the "Sign up" link on the login page when it would only
lead to a closed-signup notice. In invite mode this is True exactly for
visitors who followed an invitation link, so the link appears for the
people it is meant for and stays hidden for everyone else.
"""
from apps.accounts.adapters import signup_allowed as _signup_allowed

return _signup_allowed(context.get("request"))


@register.filter(is_safe=True)
def json_attr(value):
"""Serialize a Python value as a JSON literal safe to embed inside an
Expand Down
21 changes: 21 additions & 0 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,27 @@
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
LOGIN_REDIRECT_URL = "/"
ACCOUNT_LOGOUT_REDIRECT_URL = "/accounts/login/"
ACCOUNT_ADAPTER = "apps.accounts.adapters.AccountAdapter"

# Who may create an account. Applies to both the email form and social
# logins; signing in with an existing account is never affected.
#
# open anyone can sign up (default — unchanged behaviour)
# invite only visitors who followed a valid invitation link
# closed nobody; accounts are created in the Django admin
#
# A deployment reachable from the internet — which it has to be, so the
# platforms can deliver OAuth callbacks and webhooks — is open to the world
# on "open". Self-hosters usually want "invite".
SIGNUP_MODE = env("SIGNUP_MODE", default="open").strip().lower()
if SIGNUP_MODE not in {"open", "invite", "closed"}:
from django.core.exceptions import ImproperlyConfigured

raise ImproperlyConfigured(
f"SIGNUP_MODE must be one of open, invite, closed — got {SIGNUP_MODE!r}. "
"Refusing to guess, because falling back to a default would silently "
"leave signup open."
)

AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
Expand Down
5 changes: 4 additions & 1 deletion templates/account/login.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% load static i18n allauth account socialaccount %}
{% load static i18n allauth account socialaccount common_extras %}

{% block title %}Sign in · BrightBean Studio{% endblock %}

Expand Down Expand Up @@ -120,9 +120,12 @@ <h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary);
</div>

<!-- Footer link -->
{% signup_allowed as can_signup %}
{% if can_signup %}
<p class="text-center text-sm mt-6" style="color: var(--text-secondary);">
Don't have an account?
<a href="{{ signup_url }}" class="font-semibold hover:underline" style="color: var(--primary);">Sign up</a>
</p>
{% endif %}
</div>
{% endblock %}
29 changes: 29 additions & 0 deletions templates/account/signup_closed.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% load static i18n %}

{% block title %}Sign Up Closed - Brightbean{% endblock %}

{% block auth_content %}
<div class="w-full max-w-md px-4">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-12 h-12 rounded-2xl mb-4" style="background: var(--neutral-100);">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--neutral-500)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</div>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary);">
{% trans "Sign-up is closed" %}
</h1>
<p class="text-sm mt-1" style="color: var(--text-secondary);">
{% trans "This installation does not accept new accounts. Ask an administrator for an invitation." %}
</p>
</div>

<div class="auth-card">
<a href="{% url 'account_login' %}" class="btn-brand w-full text-center block">
{% trans "Back to sign in" %}
</a>
</div>
</div>
{% endblock %}