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:
parent
b9378b8397
commit
99f810d043
25 changed files with 281 additions and 42 deletions
|
|
@ -9,8 +9,9 @@ jobs:
|
|||
# Adjust to match the labels of your Forgejo runner.
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
- uses: https://github.com/astral-sh/setup-uv@v6
|
||||
# Actions are pinned to commits so a moved tag can't change what runs here.
|
||||
- uses: https://data.forgejo.org/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
- uses: https://github.com/astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
|
||||
with:
|
||||
version: "0.13.6"
|
||||
- run: uv sync --locked
|
||||
|
|
@ -23,8 +24,8 @@ jobs:
|
|||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
- uses: https://github.com/superfly/flyctl-actions/setup-flyctl@master
|
||||
- uses: https://data.forgejo.org/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
- uses: https://github.com/superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # master, 2026-10
|
||||
- run: flyctl deploy --remote-only
|
||||
env:
|
||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||
|
|
|
|||
|
|
@ -23,13 +23,17 @@ CMD ["sh", "-c", "python manage.py migrate --noinput && python manage.py runserv
|
|||
|
||||
FROM base AS prod
|
||||
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 \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --locked --no-install-project --no-dev
|
||||
COPY litestream.yml /etc/litestream.yml
|
||||
COPY . .
|
||||
# Placeholder settings: collectstatic needs the prod settings to import, nothing more.
|
||||
RUN DJANGO_SETTINGS_MODULE=config.settings.prod DJANGO_SECRET_KEY=collectstatic \
|
||||
EMAIL_BACKEND=django.core.mail.backends.dummy.EmailBackend \
|
||||
python manage.py collectstatic --noinput
|
||||
ENV DJANGO_SETTINGS_MODULE=config.settings.prod
|
||||
EXPOSE 8000
|
||||
|
|
|
|||
|
|
@ -3,16 +3,24 @@
|
|||
# because release machines don't mount the volume that holds the SQLite database.
|
||||
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"
|
||||
SERVER="gunicorn config.wsgi --bind 0.0.0.0:8000 --workers 3 --access-logfile -"
|
||||
|
||||
if [ -z "${LITESTREAM_BUCKET:-}" ]; then
|
||||
echo "LITESTREAM_BUCKET not set: running without backups" >&2
|
||||
python manage.py migrate --noinput
|
||||
python manage.py createcachetable
|
||||
exec $SERVER
|
||||
fi
|
||||
|
||||
# On a fresh volume, pull the latest backup before anything touches the database.
|
||||
litestream restore -if-db-not-exists -if-replica-exists "$DB"
|
||||
python manage.py migrate --noinput
|
||||
python manage.py createcachetable
|
||||
exec litestream replicate -exec "$SERVER"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
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_TIMEOUT = 600
|
||||
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 = {
|
||||
"github": {"SCOPE": ["read:user", "user:email"]},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,3 +5,5 @@ SECRET_KEY = SECRET_KEY or "dev-insecure-secret-key" # noqa: F405
|
|||
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]"]
|
||||
|
||||
DATA_DIR.mkdir(exist_ok=True) # noqa: F405
|
||||
# Avoid needing `createcachetable` for local development.
|
||||
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ from .base import * # noqa: F403
|
|||
|
||||
if not SECRET_KEY: # noqa: F405
|
||||
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]
|
||||
CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in ALLOWED_HOSTS]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from .base import * # noqa: F403
|
|||
|
||||
SECRET_KEY = "test-secret-key"
|
||||
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
|
||||
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
|
||||
STORAGES = {
|
||||
**STORAGES, # noqa: F405
|
||||
"default": {"BACKEND": "django.core.files.storage.InMemoryStorage"},
|
||||
|
|
|
|||
|
|
@ -1,14 +1,31 @@
|
|||
import allauth.urls
|
||||
from allauth.account import urls as allauth_account_urls
|
||||
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.orgs.urls import invite_urlpatterns
|
||||
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 = [
|
||||
path("", views.home, name="home"),
|
||||
path("admin/", admin.site.urls),
|
||||
path("accounts/", include("allauth.urls")),
|
||||
path("accounts/", include(account_urlpatterns)),
|
||||
path("invites/", include(invite_urlpatterns)),
|
||||
# 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"),
|
||||
|
|
|
|||
13
docs/plan.md
13
docs/plan.md
|
|
@ -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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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 |
|
||||
|---|---|
|
||||
| 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. |
|
||||
| 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). |
|
||||
| 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). 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. |
|
||||
| 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. |
|
||||
|
|
@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
- **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
|
||||
- [ ] Keyboard-only walkthrough of the example flow
|
||||
- [ ] 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,3 +7,10 @@ from .models import User
|
|||
@admin.register(User)
|
||||
class IntentUserAdmin(UserAdmin):
|
||||
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
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ from markdown_it import MarkdownIt
|
|||
|
||||
_ALLOWED_ATTRIBUTES = {tag: set(attrs) for tag, attrs in nh3.ALLOWED_ATTRIBUTES.items()}
|
||||
_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):
|
||||
|
|
@ -33,4 +36,4 @@ _md = _build()
|
|||
|
||||
def render(text: str) -> SafeString:
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ class OrganizationAddForm(forms.ModelForm):
|
|||
model = Organization
|
||||
fields = ["name", "slug", "owner"]
|
||||
|
||||
def clean_slug(self):
|
||||
return self.cleaned_data["slug"].lower()
|
||||
|
||||
|
||||
class OrgMembershipInline(admin.TabularInline):
|
||||
model = OrgMembership
|
||||
|
|
|
|||
21
intent/orgs/migrations/0002_orginvite_claimed_by.py
Normal file
21
intent/orgs/migrations/0002_orginvite_claimed_by.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -105,6 +105,15 @@ class OrgInvite(BaseModel):
|
|||
)
|
||||
expires_at = models.DateTimeField(default=_expiry)
|
||||
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.
|
||||
accepted_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Missing permissions raise PermissionDenied.
|
|||
"""
|
||||
|
||||
from allauth.account.models import EmailAddress
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
|
|
@ -15,6 +16,12 @@ from intent.access import org_access
|
|||
from .models import InviteStatus, Organization, OrgInvite, OrgMembership, Role
|
||||
|
||||
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):
|
||||
|
|
@ -22,6 +29,20 @@ def _require_owner(by, org):
|
|||
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:
|
||||
return (
|
||||
membership.role == Role.OWNER
|
||||
|
|
@ -42,7 +63,7 @@ def create_org(*, slug, name, owner) -> Organization:
|
|||
if owner.is_staff:
|
||||
raise ValidationError("Administrator accounts cannot own organizations.")
|
||||
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)
|
||||
return org
|
||||
|
||||
|
|
@ -56,6 +77,8 @@ def invite(org, by, *, user=None, email="", role=Role.COLLABORATOR) -> tuple[Org
|
|||
email = email.strip().lower()
|
||||
if bool(user) == bool(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():
|
||||
if user:
|
||||
|
|
@ -79,10 +102,14 @@ def invite(org, by, *, user=None, email="", role=Role.COLLABORATOR) -> tuple[Org
|
|||
|
||||
|
||||
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 (
|
||||
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")
|
||||
)
|
||||
|
||||
|
|
@ -90,9 +117,31 @@ def open_invites_for(user):
|
|||
def is_addressed_to(invite, user) -> bool:
|
||||
if invite.user_id:
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
with transaction.atomic():
|
||||
invite.refresh_from_db()
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ urlpatterns = [
|
|||
invite_urlpatterns = (
|
||||
[
|
||||
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("t/<str:token>/", views.invite_by_token, name="by_token"),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from django.contrib import messages
|
|||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.core.mail import send_mail
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse
|
||||
|
|
@ -145,21 +146,37 @@ def _respond(request, invite):
|
|||
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
|
||||
@require_POST
|
||||
def invite_respond(request, invite_id):
|
||||
invite = get_object_or_404(OrgInvite.objects.select_related("org"), pk=invite_id)
|
||||
if not services.is_addressed_to(invite, request.user):
|
||||
raise PermissionDenied
|
||||
return _respond(request, invite)
|
||||
return _respond(request, _addressed_invite(request, invite_id))
|
||||
|
||||
|
||||
@login_required
|
||||
def invite_by_token(request, token):
|
||||
"""Email invites: whoever holds the link may accept, whichever account they sign in with."""
|
||||
invite = get_object_or_404(
|
||||
OrgInvite.objects.select_related("org", "invited_by"), token_hash=hash_token(token)
|
||||
)
|
||||
if request.method == "POST":
|
||||
return _respond(request, invite)
|
||||
return render(request, "orgs/invite.html", {"invite": invite})
|
||||
"""Email invite links. The first signed-in visitor claims the invite and the link dies.
|
||||
|
||||
Nothing is shown at this URL: the token appears in request logs, so it must be worthless
|
||||
the moment it has been used once.
|
||||
"""
|
||||
invite = get_object_or_404(OrgInvite, token_hash=hash_token(token))
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ from functools import wraps
|
|||
from django.contrib.auth.views import redirect_to_login
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
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, ...).
|
||||
|
||||
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):
|
||||
@wraps(view)
|
||||
def wrapper(request, org_slug, project_slug, **kwargs):
|
||||
project = get_object_or_404(
|
||||
Project.objects.select_related("org"), org__slug=org_slug, slug=project_slug
|
||||
project = (
|
||||
Project.objects.select_related("org")
|
||||
.filter(org__slug=org_slug, slug=project_slug)
|
||||
.first()
|
||||
)
|
||||
access = project_access(request.user, project)
|
||||
if not access.can_view or not getattr(access, require):
|
||||
access = project_access(request.user, project) if project else None
|
||||
if access is None or not access.can_view or not getattr(access, require):
|
||||
if not request.user.is_authenticated:
|
||||
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 PermissionDenied
|
||||
return view(request, project, access, **kwargs)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ class ProjectCreateForm(forms.ModelForm):
|
|||
}
|
||||
widgets = {"visibility": forms.RadioSelect}
|
||||
|
||||
def clean_slug(self):
|
||||
return self.cleaned_data["slug"].lower()
|
||||
|
||||
def clean_feature_prefix(self):
|
||||
return self.cleaned_data["feature_prefix"].upper()
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ def create_project(org, by, *, slug, name, feature_prefix, visibility, open_cont
|
|||
raise PermissionDenied
|
||||
project = Project(
|
||||
org=org,
|
||||
slug=slug,
|
||||
# Slugs are lowercased so two projects can't differ only by case in the URL.
|
||||
slug=slug.lower(),
|
||||
name=name,
|
||||
feature_prefix=feature_prefix.upper(),
|
||||
visibility=visibility,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
{% 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>.
|
||||
</p>
|
||||
<form method="post">
|
||||
<form method="post" action="{% url 'invites:respond' invite_id=invite.pk %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" name="action" value="accept">Accept</button>
|
||||
<button type="submit" name="action" value="decline">Decline</button>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import re
|
|||
import pytest
|
||||
from django.core import mail
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.urls import reverse
|
||||
from django.urls import NoReverseMatch, reverse
|
||||
|
||||
from intent.accounts.validators import username_validators
|
||||
|
||||
|
|
@ -50,3 +50,28 @@ def test_invalid_usernames(value):
|
|||
with pytest.raises(ValidationError):
|
||||
for validator in username_validators:
|
||||
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
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ def test_raw_html_and_script_links_are_neutralised():
|
|||
assert "onerror" not in html.replace("onerror=y>", "")
|
||||
|
||||
|
||||
def test_remote_images_are_dropped():
|
||||
assert "<img" not in render("")
|
||||
|
||||
|
||||
def test_tables_and_strikethrough():
|
||||
html = render("| a |\n|---|\n| 1 |\n\n~~old~~")
|
||||
assert "<table" in html and "<s>old</s>" in html
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import re
|
||||
|
||||
import pytest
|
||||
from django.core import mail
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.urls import reverse
|
||||
|
||||
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["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")
|
||||
client.force_login(joiner)
|
||||
assert client.get(path).status_code == 200
|
||||
client.post(path, {"action": "accept"})
|
||||
invite = OrgInvite.objects.get(email="new@example.com")
|
||||
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 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):
|
||||
|
|
|
|||
|
|
@ -67,10 +67,14 @@ def test_reserved_slug_rejected(owner, org):
|
|||
|
||||
|
||||
def test_private_project_hidden_from_outsiders(client, make_user, project):
|
||||
response = client.get(url("home", project))
|
||||
assert response.status_code == 302 and "/accounts/login/" in response["Location"]
|
||||
missing = f"/{project.org.slug}/no-such-project/"
|
||||
# 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())
|
||||
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):
|
||||
|
|
@ -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()
|
||||
client.force_login(collaborator)
|
||||
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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue