Harden auth, invites and deployment after security review

- Signed-out visitors get the same login redirect for missing and
  private projects, so the redirect no longer reveals private ones.
- Production refuses the console email backend; sign-in codes must
  never reach the logs.
- Database cache shared by all workers for allauth rate limits; invite
  sending throttled per sender and per recipient.
- Email invite links are claimed on first use and the token rotated,
  so a token copied from request logs is worthless.
- allauth password URLs removed; adding an email requires a fresh code
  and notifies existing addresses.
- Markdown drops remote images until uploads exist.
- Project and org slugs lowercased; only superusers can grant staff
  status or permissions in the admin.
- Container runs as a non-root user; CI actions pinned to commits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Alec K2XAP 2026-10-27 08:42:19 -04:00
parent b9378b8397
commit 99f810d043
25 changed files with 281 additions and 42 deletions

View file

@ -9,8 +9,9 @@ jobs:
# Adjust to match the labels of your Forgejo runner. # Adjust to match the labels of your Forgejo runner.
runs-on: docker runs-on: docker
steps: steps:
- uses: https://data.forgejo.org/actions/checkout@v4 # Actions are pinned to commits so a moved tag can't change what runs here.
- uses: https://github.com/astral-sh/setup-uv@v6 - uses: https://data.forgejo.org/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: https://github.com/astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
with: with:
version: "0.13.6" version: "0.13.6"
- run: uv sync --locked - run: uv sync --locked
@ -23,8 +24,8 @@ jobs:
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: docker runs-on: docker
steps: steps:
- uses: https://data.forgejo.org/actions/checkout@v4 - uses: https://data.forgejo.org/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: https://github.com/superfly/flyctl-actions/setup-flyctl@master - uses: https://github.com/superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # master, 2026-10
- run: flyctl deploy --remote-only - run: flyctl deploy --remote-only
env: env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

View file

@ -23,13 +23,17 @@ CMD ["sh", "-c", "python manage.py migrate --noinput && python manage.py runserv
FROM base AS prod FROM base AS prod
COPY --from=litestream/litestream:0.5.17 /usr/local/bin/litestream /usr/local/bin/litestream COPY --from=litestream/litestream:0.5.17 /usr/local/bin/litestream /usr/local/bin/litestream
# The app runs as this user; bin/start drops to it after making the data volume writable.
RUN useradd --system --uid 1000 --create-home app
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev uv sync --locked --no-install-project --no-dev
COPY litestream.yml /etc/litestream.yml COPY litestream.yml /etc/litestream.yml
COPY . . COPY . .
# Placeholder settings: collectstatic needs the prod settings to import, nothing more.
RUN DJANGO_SETTINGS_MODULE=config.settings.prod DJANGO_SECRET_KEY=collectstatic \ RUN DJANGO_SETTINGS_MODULE=config.settings.prod DJANGO_SECRET_KEY=collectstatic \
EMAIL_BACKEND=django.core.mail.backends.dummy.EmailBackend \
python manage.py collectstatic --noinput python manage.py collectstatic --noinput
ENV DJANGO_SETTINGS_MODULE=config.settings.prod ENV DJANGO_SETTINGS_MODULE=config.settings.prod
EXPOSE 8000 EXPOSE 8000

View file

@ -3,16 +3,24 @@
# because release machines don't mount the volume that holds the SQLite database. # because release machines don't mount the volume that holds the SQLite database.
set -eu set -eu
# The volume is mounted owned by root. Take ownership for the app user, then rerun as it.
if [ "$(id -u)" = 0 ]; then
chown -R app:app "${INTENT_DATA_DIR:?}"
exec setpriv --reuid=app --regid=app --init-groups env HOME=/home/app "$0" "$@"
fi
DB="${INTENT_DATA_DIR:?}/db.sqlite3" DB="${INTENT_DATA_DIR:?}/db.sqlite3"
SERVER="gunicorn config.wsgi --bind 0.0.0.0:8000 --workers 3 --access-logfile -" SERVER="gunicorn config.wsgi --bind 0.0.0.0:8000 --workers 3 --access-logfile -"
if [ -z "${LITESTREAM_BUCKET:-}" ]; then if [ -z "${LITESTREAM_BUCKET:-}" ]; then
echo "LITESTREAM_BUCKET not set: running without backups" >&2 echo "LITESTREAM_BUCKET not set: running without backups" >&2
python manage.py migrate --noinput python manage.py migrate --noinput
python manage.py createcachetable
exec $SERVER exec $SERVER
fi fi
# On a fresh volume, pull the latest backup before anything touches the database. # On a fresh volume, pull the latest backup before anything touches the database.
litestream restore -if-db-not-exists -if-replica-exists "$DB" litestream restore -if-db-not-exists -if-replica-exists "$DB"
python manage.py migrate --noinput python manage.py migrate --noinput
python manage.py createcachetable
exec litestream replicate -exec "$SERVER" exec litestream replicate -exec "$SERVER"

View file

@ -76,6 +76,15 @@ DATABASES = {
} }
} }
# Rate limits (allauth's and ours) live here, so the cache must be shared by every worker
# and survive restarts. The database is the only shared store on a one-machine deploy.
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.db.DatabaseCache",
"LOCATION": "cache",
}
}
AUTH_USER_MODEL = "accounts.User" AUTH_USER_MODEL = "accounts.User"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
@ -100,6 +109,10 @@ ACCOUNT_LOGIN_BY_CODE_ENABLED = True
ACCOUNT_LOGIN_BY_CODE_SUPPORTS_RESEND = True ACCOUNT_LOGIN_BY_CODE_SUPPORTS_RESEND = True
ACCOUNT_LOGIN_BY_CODE_TIMEOUT = 600 ACCOUNT_LOGIN_BY_CODE_TIMEOUT = 600
ACCOUNT_LOGOUT_ON_GET = False ACCOUNT_LOGOUT_ON_GET = False
# Adding an email address adds a way to sign in, so ask for a fresh code first, and tell
# the existing addresses about it.
ACCOUNT_REAUTHENTICATION_REQUIRED = True
ACCOUNT_EMAIL_NOTIFICATIONS = True
SOCIALACCOUNT_PROVIDERS = { SOCIALACCOUNT_PROVIDERS = {
"github": {"SCOPE": ["read:user", "user:email"]}, "github": {"SCOPE": ["read:user", "user:email"]},
} }

View file

@ -5,3 +5,5 @@ SECRET_KEY = SECRET_KEY or "dev-insecure-secret-key" # noqa: F405
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]"] ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]"]
DATA_DIR.mkdir(exist_ok=True) # noqa: F405 DATA_DIR.mkdir(exist_ok=True) # noqa: F405
# Avoid needing `createcachetable` for local development.
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}

View file

@ -7,6 +7,9 @@ from .base import * # noqa: F403
if not SECRET_KEY: # noqa: F405 if not SECRET_KEY: # noqa: F405
raise ImproperlyConfigured("DJANGO_SECRET_KEY must be set") raise ImproperlyConfigured("DJANGO_SECRET_KEY must be set")
# The console backend would print sign-in codes and invite links into the logs.
if EMAIL_BACKEND == "django.core.mail.backends.console.EmailBackend": # noqa: F405
raise ImproperlyConfigured("EMAIL_BACKEND must be set to a real email backend in production")
ALLOWED_HOSTS = [h for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",") if h] ALLOWED_HOSTS = [h for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",") if h]
CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in ALLOWED_HOSTS] CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in ALLOWED_HOSTS]

View file

@ -2,6 +2,7 @@ from .base import * # noqa: F403
SECRET_KEY = "test-secret-key" SECRET_KEY = "test-secret-key"
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
STORAGES = { STORAGES = {
**STORAGES, # noqa: F405 **STORAGES, # noqa: F405
"default": {"BACKEND": "django.core.files.storage.InMemoryStorage"}, "default": {"BACKEND": "django.core.files.storage.InMemoryStorage"},

View file

@ -1,14 +1,31 @@
import allauth.urls
from allauth.account import urls as allauth_account_urls
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import URLResolver, include, path
from intent.core import views from intent.core import views
from intent.orgs.urls import invite_urlpatterns from intent.orgs.urls import invite_urlpatterns
from intent.projects.views import project_create from intent.projects.views import project_create
def _without_passwords(patterns):
"""allauth's URLs minus the password views: regular users sign in with a code only."""
return [p for p in patterns if not str(p.pattern).startswith("password/")]
account_urlpatterns = [
(
path("", include(_without_passwords(allauth_account_urls.urlpatterns)))
if isinstance(p, URLResolver) and p.urlconf_name is allauth_account_urls
else p
)
for p in allauth.urls.urlpatterns
]
urlpatterns = [ urlpatterns = [
path("", views.home, name="home"), path("", views.home, name="home"),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("accounts/", include("allauth.urls")), path("accounts/", include(account_urlpatterns)),
path("invites/", include(invite_urlpatterns)), path("invites/", include(invite_urlpatterns)),
# Organizations own the rest of the URL space (see RESERVED_SLUGS in orgs and projects). # Organizations own the rest of the URL space (see RESERVED_SLUGS in orgs and projects).
path("<slug:org_slug>/new/", project_create, name="project_create"), path("<slug:org_slug>/new/", project_create, name="project_create"),

View file

@ -34,10 +34,10 @@ Decisions made in the concept are not repeated. These fill the gaps.
| Thread → issue | Original commenter or any team member. Thread gets `promoted_to` issue; issue body is prefilled with the thread text and links back. | | Thread → issue | Original commenter or any team member. Thread gets `promoted_to` issue; issue body is prefilled with the thread text and links back. |
| Deleted users | Content remains, attributed to a "ghost" user. | | Deleted users | Content remains, attributed to a "ghost" user. |
| Moderation | Project owners can delete comments and close issues in their project. Sysadmin can deactivate a user site-wide. | | Moderation | Project owners can delete comments and close issues in their project. Sysadmin can deactivate a user site-wide. |
| Invites | By email or existing username. Username invites appear on the invitee's Invitations page (no notifications in V1). Email invites send a link carrying a random token (only its hash is stored); the link requires sign-in and whoever holds it may accept with any account. Email invites also appear on the Invitations page of a user with that verified email. Re-inviting replaces the pending invite. Expire after 14 days. The last org owner cannot be removed, demoted or leave. | | Invites | By email or existing username. Username invites appear on the invitee's Invitations page (no notifications in V1). Email invites send a link carrying a random token (only its hash is stored); the link requires sign-in and the first signed-in visitor claims the invite with whatever account they used. Claiming rotates the token, so the link (which appears in request logs) is dead after one use; the claimant then accepts or declines from their Invitations page. Unclaimed email invites also appear on the Invitations page of a user with that verified email. Re-inviting replaces the pending invite. Sending is throttled (30 per sender per hour, 3 per recipient per org per hour). Expire after 14 days. The last org owner cannot be removed, demoted or leave. |
| URLs | Organizations live at the root: `/<org>/`, `/<org>/<project>/`, `/<org>/<project>/features/INT-12/`. App routes are reserved slugs at both levels. | | URLs | Organizations live at the root: `/<org>/`, `/<org>/<project>/`, `/<org>/<project>/features/INT-12/`. App routes are reserved slugs at both levels. |
| Project slug and prefix | Fixed after creation, since they appear in URLs and feature IDs. Name, visibility and open contribution stay editable. | | Project slug and prefix | Fixed after creation, since they appear in URLs and feature IDs. Name, visibility and open contribution stay editable. |
| Hidden projects | A project the user can't see returns 404 (not 403), so private projects don't reveal they exist. Signed-out visitors are sent to sign in instead. | | Hidden projects | A project the user can't see returns 404 (not 403), so private projects don't reveal they exist. Signed-out visitors are sent to sign in instead, for missing and hidden projects alike, so the redirect reveals nothing either. Markdown drops remote images (`<img>`) until uploads exist, so an author can't log readers' IP addresses. |
| Project owners | No last-owner guard: org owners can always administer every project. Leaving the org removes a user's project roles in it. | | Project owners | No last-owner guard: org owners can always administer every project. Leaving the org removes a user's project roles in it. |
| Feature editing | One form edits title, description, parent, status and replacement, with a required change note. Saving with no changes is refused. A "Preview description" button renders the Markdown server-side, so preview works without JS. Django admin shows features read-only so every change goes through revisions. | | Feature editing | One form edits title, description, parent, status and replacement, with a required change note. Saving with no changes is refused. A "Preview description" button renders the Markdown server-side, so preview works without JS. Django admin shows features read-only so every change goes through revisions. |
@ -46,8 +46,9 @@ Decisions made in the concept are not repeated. These fill the gaps.
| Topic | Default | | Topic | Default |
|---|---| |---|---|
| Runtime | Python 3.13, Django 5.2 LTS, `uv` for dependencies, `ruff` for lint/format. | | Runtime | Python 3.13, Django 5.2 LTS, `uv` for dependencies, `ruff` for lint/format. |
| Auth | `django-allauth`: GitHub, Discord, OpenID Connect provider per Forgejo instance (Forgejo supports OIDC). Sysadmins register each provider as a SocialApp in Django admin, so adding a Forgejo instance needs no deploy. Email sign-in is allauth's login-by-code: the "magic link" is an emailed one-time code, not a clickable link. Signup asks for email and username only; regular users never get a password. Multiple social accounts per user via allauth's connect flow (no automatic linking by email). Usernames: letters, digits, `-`, `_`, max 39. | | Auth | `django-allauth`: GitHub, Discord, OpenID Connect provider per Forgejo instance (Forgejo supports OIDC). Sysadmins register each provider as a SocialApp in Django admin, so adding a Forgejo instance needs no deploy. Email sign-in is allauth's login-by-code: the "magic link" is an emailed one-time code, not a clickable link. Signup asks for email and username only; regular users never get a password, and allauth's password URLs are not routed at all. Adding an email address requires re-entering a fresh code, and existing addresses are notified. Multiple social accounts per user via allauth's connect flow (no automatic linking by email). Usernames: letters, digits, `-`, `_`, max 39. |
| Sysadmins | Django `is_staff` users with passwords, Django admin at `/admin/`. A model constraint rejects staff users as org members. Regular users have no password (unusable). | | Sysadmins | Django `is_staff` users with passwords, Django admin at `/admin/`. A model constraint rejects staff users as org members. Regular users have no password (unusable). Only superusers can grant staff or superuser status or permissions in the admin. |
| Cache | Django's database cache (`createcachetable` runs at start). It holds allauth's rate limits and the invite throttle, so it must be shared by all gunicorn workers and survive restarts; the database is the only shared store on a one-machine deploy. Dev and tests use the in-memory cache. |
| Database | SQLite on a Fly volume. `transaction_mode = "IMMEDIATE"`, WAL mode, `busy_timeout` 5 s. Litestream replicates to B2 continuously. | | Database | SQLite on a Fly volume. `transaction_mode = "IMMEDIATE"`, WAL mode, `busy_timeout` 5 s. Litestream replicates to B2 continuously. |
| Files | Tigris via `django-storages` S3 backend, private bucket, time-limited signed URLs. HTML prototypes are shown in a sandboxed `<iframe>` whose `src` is a signed URL on the Tigris origin, never rendered inline on the app origin. | | Files | Tigris via `django-storages` S3 backend, private bucket, time-limited signed URLs. HTML prototypes are shown in a sandboxed `<iframe>` whose `src` is a signed URL on the Tigris origin, never rendered inline on the app origin. |
| Markdown | `markdown-it-py` with source line mapping (`data-line` attributes on blocks) for line threads; output sanitized with `nh3`. Diffs via `difflib` rendered server-side. | | Markdown | `markdown-it-py` with source line mapping (`data-line` attributes on blocks) for line threads; output sanitized with `nh3`. Diffs via `difflib` rendered server-side. |
@ -185,7 +186,7 @@ Each phase ends deployable. Rough sizes assume one person, part-time; adjust fre
- Pick email provider, configure anymail, verify magic link and invite emails. - Pick email provider, configure anymail, verify magic link and invite emails.
- Moderation: deactivate user (sysadmin), delete comments (project owner), ghost user on account deletion. - Moderation: deactivate user (sysadmin), delete comments (project owner), ghost user on account deletion.
- Playwright smoke tests with axe checks on the main pages in both color schemes. - Playwright smoke tests with axe checks on the main pages in both color schemes.
- Rate limiting on login and comment endpoints (`django-ratelimit`), CSP header, security headers review. - Rate limiting on comment endpoints (allauth already limits its own views; invites are throttled), CSP header, security headers review, second factor for `/admin/`.
- Backup restore drill repeated against the real volume. - Backup restore drill repeated against the real volume.
- **Exit:** launch checklist (below) complete. - **Exit:** launch checklist (below) complete.
@ -199,6 +200,8 @@ Each phase ends deployable. Rough sizes assume one person, part-time; adjust fre
- [ ] axe passes on feature, issue, project and org pages, light and dark - [ ] axe passes on feature, issue, project and org pages, light and dark
- [ ] Keyboard-only walkthrough of the example flow - [ ] Keyboard-only walkthrough of the example flow
- [ ] Privacy check: private project content not reachable via file URLs, feeds, or admin leaks - [ ] Privacy check: private project content not reachable via file URLs, feeds, or admin leaks
- [ ] `EMAIL_BACKEND` set in production (prod settings refuse the console backend)
- [ ] Container confirmed running as the `app` user on the Fly volume
## 7. Hooks for V1.1 ## 7. Hooks for V1.1

View file

@ -7,3 +7,10 @@ from .models import User
@admin.register(User) @admin.register(User)
class IntentUserAdmin(UserAdmin): class IntentUserAdmin(UserAdmin):
search_fields = ["username", "email"] search_fields = ["username", "email"]
def get_readonly_fields(self, request, obj=None):
# Only superusers may hand out staff or superuser status, or permissions.
fields = list(super().get_readonly_fields(request, obj))
if not request.user.is_superuser:
fields += ["is_staff", "is_superuser", "groups", "user_permissions"]
return fields

View file

@ -11,6 +11,9 @@ from markdown_it import MarkdownIt
_ALLOWED_ATTRIBUTES = {tag: set(attrs) for tag, attrs in nh3.ALLOWED_ATTRIBUTES.items()} _ALLOWED_ATTRIBUTES = {tag: set(attrs) for tag, attrs in nh3.ALLOWED_ATTRIBUTES.items()}
_ALLOWED_ATTRIBUTES["*"] = {"data-source-lines"} _ALLOWED_ATTRIBUTES["*"] = {"data-source-lines"}
# No remote images: they would let an author log every reader's IP address.
# Uploaded images arrive in Phase 4 and will be served from our own storage.
_ALLOWED_TAGS = nh3.ALLOWED_TAGS - {"img"}
def _annotate(state): def _annotate(state):
@ -33,4 +36,4 @@ _md = _build()
def render(text: str) -> SafeString: def render(text: str) -> SafeString:
html = _md.render(text or "") html = _md.render(text or "")
return mark_safe(nh3.clean(html, attributes=_ALLOWED_ATTRIBUTES)) return mark_safe(nh3.clean(html, tags=_ALLOWED_TAGS, attributes=_ALLOWED_ATTRIBUTES))

View file

@ -15,6 +15,9 @@ class OrganizationAddForm(forms.ModelForm):
model = Organization model = Organization
fields = ["name", "slug", "owner"] fields = ["name", "slug", "owner"]
def clean_slug(self):
return self.cleaned_data["slug"].lower()
class OrgMembershipInline(admin.TabularInline): class OrgMembershipInline(admin.TabularInline):
model = OrgMembership model = OrgMembership

View file

@ -0,0 +1,21 @@
# Generated by Django 5.2.18 on 2026-10-27 12:34
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orgs', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='orginvite',
name='claimed_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
),
]

View file

@ -105,6 +105,15 @@ class OrgInvite(BaseModel):
) )
expires_at = models.DateTimeField(default=_expiry) expires_at = models.DateTimeField(default=_expiry)
responded_at = models.DateTimeField(null=True, blank=True) responded_at = models.DateTimeField(null=True, blank=True)
# Email invites: the first signed-in user to open the link claims the invite. The link
# stops working at that point, so a token copied from a log or a referrer is useless.
claimed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
# Who actually accepted. For email invites, whoever held the link. # Who actually accepted. For email invites, whoever held the link.
accepted_by = models.ForeignKey( accepted_by = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,

View file

@ -5,6 +5,7 @@ Missing permissions raise PermissionDenied.
""" """
from allauth.account.models import EmailAddress from allauth.account.models import EmailAddress
from django.core.cache import cache
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction from django.db import transaction
from django.db.models import Q from django.db.models import Q
@ -15,6 +16,12 @@ from intent.access import org_access
from .models import InviteStatus, Organization, OrgInvite, OrgMembership, Role from .models import InviteStatus, Organization, OrgInvite, OrgMembership, Role
LAST_OWNER_MESSAGE = "An organization needs at least one owner. Make someone else an owner first." LAST_OWNER_MESSAGE = "An organization needs at least one owner. Make someone else an owner first."
TOO_MANY_INVITES_MESSAGE = "Too many invitations sent recently. Try again later."
# Each invite can send an email, so cap how many one person sends and how often one
# address is hit, with room for genuine use.
INVITES_PER_SENDER = (30, 60 * 60)
INVITES_PER_RECIPIENT = (3, 60 * 60)
def _require_owner(by, org): def _require_owner(by, org):
@ -22,6 +29,20 @@ def _require_owner(by, org):
raise PermissionDenied raise PermissionDenied
def _throttle(key: str, limit: int, seconds: int) -> None:
"""Count an event under `key`; raise once more than `limit` happen within `seconds`."""
key = f"intent:throttle:{key}"
if cache.add(key, 1, timeout=seconds):
return
try:
count = cache.incr(key)
except ValueError: # Expired between add and incr.
cache.add(key, 1, timeout=seconds)
return
if count > limit:
raise ValidationError(TOO_MANY_INVITES_MESSAGE)
def _is_last_owner(membership) -> bool: def _is_last_owner(membership) -> bool:
return ( return (
membership.role == Role.OWNER membership.role == Role.OWNER
@ -42,7 +63,7 @@ def create_org(*, slug, name, owner) -> Organization:
if owner.is_staff: if owner.is_staff:
raise ValidationError("Administrator accounts cannot own organizations.") raise ValidationError("Administrator accounts cannot own organizations.")
with transaction.atomic(): with transaction.atomic():
org = Organization.objects.create(slug=slug, name=name) org = Organization.objects.create(slug=slug.lower(), name=name)
OrgMembership.objects.create(org=org, user=owner, role=Role.OWNER) OrgMembership.objects.create(org=org, user=owner, role=Role.OWNER)
return org return org
@ -56,6 +77,8 @@ def invite(org, by, *, user=None, email="", role=Role.COLLABORATOR) -> tuple[Org
email = email.strip().lower() email = email.strip().lower()
if bool(user) == bool(email): if bool(user) == bool(email):
raise ValueError("Invite exactly one of user or email") raise ValueError("Invite exactly one of user or email")
_throttle(f"invites-by:{by.pk}", *INVITES_PER_SENDER)
_throttle(f"invites-to:{org.pk}:{user.pk if user else email}", *INVITES_PER_RECIPIENT)
with transaction.atomic(): with transaction.atomic():
if user: if user:
@ -79,10 +102,14 @@ def invite(org, by, *, user=None, email="", role=Role.COLLABORATOR) -> tuple[Org
def open_invites_for(user): def open_invites_for(user):
"""Pending, unexpired invites addressed to this user by username or verified email.""" """Pending, unexpired invites addressed to this user: by username, verified email, or link."""
return ( return (
OrgInvite.objects.filter(status=InviteStatus.PENDING, expires_at__gt=timezone.now()) OrgInvite.objects.filter(status=InviteStatus.PENDING, expires_at__gt=timezone.now())
.filter(Q(user=user) | Q(email__in=_verified_emails(user), email__gt="")) .filter(
Q(user=user)
| Q(claimed_by=user)
| Q(email__in=_verified_emails(user), email__gt="", claimed_by__isnull=True)
)
.select_related("org", "invited_by") .select_related("org", "invited_by")
) )
@ -90,9 +117,31 @@ def open_invites_for(user):
def is_addressed_to(invite, user) -> bool: def is_addressed_to(invite, user) -> bool:
if invite.user_id: if invite.user_id:
return invite.user_id == user.pk return invite.user_id == user.pk
if invite.claimed_by_id:
return invite.claimed_by_id == user.pk
return invite.email in _verified_emails(user) return invite.email in _verified_emails(user)
def claim_invite(invite, user) -> None:
"""Bind an email invite to the signed-in user who opened its link, and retire the link.
Whoever holds the link may claim, whichever account they sign in with. Only one account
can: a second holder of the same link sees nothing.
"""
with transaction.atomic():
invite.refresh_from_db()
if invite.claimed_by_id and invite.claimed_by_id != user.pk:
raise PermissionDenied
if not invite.is_open:
raise ValidationError("This invitation is no longer valid.")
if user.is_staff:
raise ValidationError("Administrator accounts cannot join organizations.")
if invite.claimed_by_id is None:
invite.claimed_by = user
invite.set_new_token() # The old link stops working; the new token is discarded.
invite.save(update_fields=["claimed_by", "token_hash", "updated_at"])
def accept_invite(invite, user) -> OrgMembership: def accept_invite(invite, user) -> OrgMembership:
with transaction.atomic(): with transaction.atomic():
invite.refresh_from_db() invite.refresh_from_db()

View file

@ -15,6 +15,7 @@ urlpatterns = [
invite_urlpatterns = ( invite_urlpatterns = (
[ [
path("", views.my_invites, name="list"), path("", views.my_invites, name="list"),
path("<str:invite_id>/", views.invite_show, name="show"),
path("<str:invite_id>/respond/", views.invite_respond, name="respond"), path("<str:invite_id>/respond/", views.invite_respond, name="respond"),
path("t/<str:token>/", views.invite_by_token, name="by_token"), path("t/<str:token>/", views.invite_by_token, name="by_token"),
], ],

View file

@ -2,6 +2,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.core.mail import send_mail from django.core.mail import send_mail
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import reverse from django.urls import reverse
@ -145,21 +146,37 @@ def _respond(request, invite):
return redirect("invites:list") return redirect("invites:list")
def _addressed_invite(request, invite_id):
invite = get_object_or_404(OrgInvite.objects.select_related("org", "invited_by"), pk=invite_id)
if not services.is_addressed_to(invite, request.user):
raise PermissionDenied
return invite
@login_required
def invite_show(request, invite_id):
return render(request, "orgs/invite.html", {"invite": _addressed_invite(request, invite_id)})
@login_required @login_required
@require_POST @require_POST
def invite_respond(request, invite_id): def invite_respond(request, invite_id):
invite = get_object_or_404(OrgInvite.objects.select_related("org"), pk=invite_id) return _respond(request, _addressed_invite(request, invite_id))
if not services.is_addressed_to(invite, request.user):
raise PermissionDenied
return _respond(request, invite)
@login_required @login_required
def invite_by_token(request, token): def invite_by_token(request, token):
"""Email invites: whoever holds the link may accept, whichever account they sign in with.""" """Email invite links. The first signed-in visitor claims the invite and the link dies.
invite = get_object_or_404(
OrgInvite.objects.select_related("org", "invited_by"), token_hash=hash_token(token) Nothing is shown at this URL: the token appears in request logs, so it must be worthless
) the moment it has been used once.
if request.method == "POST": """
return _respond(request, invite) invite = get_object_or_404(OrgInvite, token_hash=hash_token(token))
return render(request, "orgs/invite.html", {"invite": invite}) try:
services.claim_invite(invite, request.user)
except PermissionDenied:
raise Http404 from None
except ValidationError as exc:
_error(request, exc)
return redirect("invites:list")
return redirect("invites:show", invite_id=invite.pk)

View file

@ -3,7 +3,6 @@ from functools import wraps
from django.contrib.auth.views import redirect_to_login from django.contrib.auth.views import redirect_to_login
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404
from intent.access import project_access from intent.access import project_access
@ -14,20 +13,23 @@ def project_view(require="can_view"):
"""Load the project from the URL, check access, and call view(request, project, access, ...). """Load the project from the URL, check access, and call view(request, project, access, ...).
Projects a user can't see are 404s, so private projects don't reveal they exist. Projects a user can't see are 404s, so private projects don't reveal they exist.
Signed-out visitors are sent to sign in instead. Signed-out visitors are sent to sign in instead, whether or not the project exists,
so the redirect doesn't reveal it either.
""" """
def decorator(view): def decorator(view):
@wraps(view) @wraps(view)
def wrapper(request, org_slug, project_slug, **kwargs): def wrapper(request, org_slug, project_slug, **kwargs):
project = get_object_or_404( project = (
Project.objects.select_related("org"), org__slug=org_slug, slug=project_slug Project.objects.select_related("org")
.filter(org__slug=org_slug, slug=project_slug)
.first()
) )
access = project_access(request.user, project) access = project_access(request.user, project) if project else None
if not access.can_view or not getattr(access, require): if access is None or not access.can_view or not getattr(access, require):
if not request.user.is_authenticated: if not request.user.is_authenticated:
return redirect_to_login(request.get_full_path()) return redirect_to_login(request.get_full_path())
if not access.can_view: if access is None or not access.can_view:
raise Http404 raise Http404
raise PermissionDenied raise PermissionDenied
return view(request, project, access, **kwargs) return view(request, project, access, **kwargs)

View file

@ -21,6 +21,9 @@ class ProjectCreateForm(forms.ModelForm):
} }
widgets = {"visibility": forms.RadioSelect} widgets = {"visibility": forms.RadioSelect}
def clean_slug(self):
return self.cleaned_data["slug"].lower()
def clean_feature_prefix(self): def clean_feature_prefix(self):
return self.cleaned_data["feature_prefix"].upper() return self.cleaned_data["feature_prefix"].upper()

View file

@ -19,7 +19,8 @@ def create_project(org, by, *, slug, name, feature_prefix, visibility, open_cont
raise PermissionDenied raise PermissionDenied
project = Project( project = Project(
org=org, org=org,
slug=slug, # Slugs are lowercased so two projects can't differ only by case in the URL.
slug=slug.lower(),
name=name, name=name,
feature_prefix=feature_prefix.upper(), feature_prefix=feature_prefix.upper(),
visibility=visibility, visibility=visibility,

View file

@ -9,7 +9,7 @@
{% if invite.invited_by %}{{ invite.invited_by.username }} invited you{% else %}You're invited{% endif %} {% if invite.invited_by %}{{ invite.invited_by.username }} invited you{% else %}You're invited{% endif %}
to join as {{ invite.get_role_display|lower }}. You'll join as <strong>{{ user.username }}</strong>. to join as {{ invite.get_role_display|lower }}. You'll join as <strong>{{ user.username }}</strong>.
</p> </p>
<form method="post"> <form method="post" action="{% url 'invites:respond' invite_id=invite.pk %}">
{% csrf_token %} {% csrf_token %}
<button type="submit" name="action" value="accept">Accept</button> <button type="submit" name="action" value="accept">Accept</button>
<button type="submit" name="action" value="decline">Decline</button> <button type="submit" name="action" value="decline">Decline</button>

View file

@ -3,7 +3,7 @@ import re
import pytest import pytest
from django.core import mail from django.core import mail
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import NoReverseMatch, reverse
from intent.accounts.validators import username_validators from intent.accounts.validators import username_validators
@ -50,3 +50,28 @@ def test_invalid_usernames(value):
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
for validator in username_validators: for validator in username_validators:
validator(value) validator(value)
@pytest.mark.parametrize(
"name", ["account_set_password", "account_change_password", "account_reset_password"]
)
def test_password_urls_do_not_exist(name):
with pytest.raises(NoReverseMatch):
reverse(name)
def test_signed_in_pages_without_passwords_render(client, owner):
client.force_login(owner)
assert client.get(reverse("account_email")).status_code == 200
assert client.get("/accounts/password/set/").status_code == 404
def test_staff_cannot_grant_staff_without_superuser(client, make_user):
from django.contrib.auth.models import Permission
staff = make_user("stan", is_staff=True)
staff.user_permissions.add(*Permission.objects.filter(content_type__app_label="accounts"))
client.force_login(staff)
target = make_user("tina")
page = client.get(reverse("admin:accounts_user_change", args=[target.pk])).content.decode()
assert 'name="is_superuser"' not in page and 'name="is_staff"' not in page

View file

@ -21,6 +21,10 @@ def test_raw_html_and_script_links_are_neutralised():
assert "onerror" not in html.replace("onerror=y&gt;", "") assert "onerror" not in html.replace("onerror=y&gt;", "")
def test_remote_images_are_dropped():
assert "<img" not in render("![pixel](https://tracker.example/p.gif)")
def test_tables_and_strikethrough(): def test_tables_and_strikethrough():
html = render("| a |\n|---|\n| 1 |\n\n~~old~~") html = render("| a |\n|---|\n| 1 |\n\n~~old~~")
assert "<table" in html and "<s>old</s>" in html assert "<table" in html and "<s>old</s>" in html

View file

@ -1,6 +1,8 @@
import re import re
import pytest
from django.core import mail from django.core import mail
from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
from intent.orgs.models import InviteStatus, OrgInvite, Role from intent.orgs.models import InviteStatus, OrgInvite, Role
@ -68,13 +70,40 @@ def test_email_invite_link_flow(client, owner, make_user, org):
assert response.status_code == 302 assert response.status_code == 302
assert response["Location"].startswith(reverse("account_login")) assert response["Location"].startswith(reverse("account_login"))
# Bearer link: any signed-in account can accept, whatever its email. # Bearer link: any signed-in account can claim it, whatever its email.
joiner = make_user("june", email="june@elsewhere.example") joiner = make_user("june", email="june@elsewhere.example")
client.force_login(joiner) client.force_login(joiner)
assert client.get(path).status_code == 200 invite = OrgInvite.objects.get(email="new@example.com")
client.post(path, {"action": "accept"}) show = reverse("invites:show", kwargs={"invite_id": invite.pk})
response = client.get(path)
assert response.status_code == 302 and response["Location"] == show
assert client.get(show).status_code == 200
assert invite in list(client.get(reverse("invites:list")).context["invites"])
# Once claimed, the link is dead for everyone else, even someone with the invited email.
other = make_user("newbie", email="new@example.com")
client.force_login(other)
assert client.get(path).status_code == 404
assert client.get(show).status_code == 403
assert client.get(reverse("invites:list")).context["invites"].count() == 0
client.force_login(joiner)
assert client.get(path).status_code == 404 # Token was rotated on claim.
client.post(reverse("invites:respond", kwargs={"invite_id": invite.pk}), {"action": "accept"})
assert org.memberships.filter(user=joiner).exists() assert org.memberships.filter(user=joiner).exists()
assert OrgInvite.objects.get(email="new@example.com").status == InviteStatus.ACCEPTED invite.refresh_from_db()
assert invite.status == InviteStatus.ACCEPTED and invite.accepted_by == joiner
def test_invite_sending_is_throttled(owner, org):
from intent.orgs import services
for _ in range(3):
services.invite(org, owner, email="same@example.com")
with pytest.raises(ValidationError, match="Too many"):
services.invite(org, owner, email="same@example.com")
# A different address still works; the per-sender cap is higher.
services.invite(org, owner, email="other@example.com")
def test_invalid_token_is_404(client, make_user): def test_invalid_token_is_404(client, make_user):

View file

@ -67,10 +67,14 @@ def test_reserved_slug_rejected(owner, org):
def test_private_project_hidden_from_outsiders(client, make_user, project): def test_private_project_hidden_from_outsiders(client, make_user, project):
response = client.get(url("home", project)) missing = f"/{project.org.slug}/no-such-project/"
assert response.status_code == 302 and "/accounts/login/" in response["Location"] # Signed-out visitors can't tell a private project from a missing one: both ask to sign in.
for path in [url("home", project), missing]:
response = client.get(path)
assert response.status_code == 302 and "/accounts/login/" in response["Location"], path
client.force_login(make_user()) client.force_login(make_user())
assert client.get(url("home", project)).status_code == 404 assert client.get(url("home", project)).status_code == 404
assert client.get(missing).status_code == 404
def test_public_project_visible_to_anonymous(client, project): def test_public_project_visible_to_anonymous(client, project):
@ -125,3 +129,12 @@ def test_org_page_lists_visible_projects(client, owner, collaborator, org, proje
assert link in client.get(org_page).content.decode() assert link in client.get(org_page).content.decode()
client.force_login(collaborator) client.force_login(collaborator)
assert link not in client.get(org_page).content.decode() assert link not in client.get(org_page).content.decode()
def test_slugs_are_lowercased(client, owner, org):
client.force_login(owner)
client.post(
reverse("project_create", kwargs={"org_slug": org.slug}),
{"name": "Docs", "slug": "Docs", "feature_prefix": "doc", "visibility": "public"},
)
assert Project.objects.filter(slug="docs").exists()