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
132 changes: 132 additions & 0 deletions apps/backend/api/adapters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""django-allauth adapters implementing LibrePhotos' OIDC/SSO login policy.

All of the security-sensitive decisions for single-sign-on live here rather than
in settings, because the adapter API is stable across allauth versions and can
read runtime state (the OIDC_* Constance flags, whether an email provider is
configured, and whether any superuser exists yet).

Policy (per the maintainer's steer on issue #401):

* **Hybrid login.** allauth only ever runs for the OIDC flow; regular
username/password login is untouched (see AUTHENTICATION_BACKENDS).
* **No local allauth signup.** LibrePhotos has its own registration; allauth must
never create an account through its own signup forms.
* **Verified email only.** An SSO login may connect to — or provision — a
LibrePhotos account only when the identity provider asserts a *verified* email.
Linking on an unverified email would let anyone who can register that address at
the IdP take over an existing LibrePhotos account.
* **Admin-provisioned by default.** A first-time SSO login creates a new account
only when the admin has turned on OIDC_ALLOW_SIGNUP *and* configured an email
provider (so the verified-email claim is meaningful). Otherwise SSO only logs in
users that already exist and whose email an admin has set to match the IdP.
* **Never privileged.** An account created via SSO is never a superuser or staff,
even during first-time setup when no superuser exists yet.
"""

from allauth.account.adapter import DefaultAccountAdapter
from allauth.core.exceptions import ImmediateHttpResponse
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from constance import config as site_config
from django.http import HttpResponseRedirect

from api.mail import email_is_configured
from api.models import User


def _login_error_redirect(reason):
"""Bounce back to the SPA login screen with a machine-readable reason.

Relative URL on purpose: the browser resolves it against the origin it is
actually talking to (the proxy), sidestepping the fact that the proxy
rewrites the Host header to ``backend`` for ``/api/`` requests.
"""
return ImmediateHttpResponse(HttpResponseRedirect(f"/login?sso_error={reason}"))


def _extract_email(sociallogin):
"""Return (email, is_verified) for the incoming social login.

Prefers allauth's parsed EmailAddress list (which reflects the provider's
``email_verified`` handling) and falls back to the raw OIDC claims.
"""
email = ""
verified = False
for address in sociallogin.email_addresses:
if address.email:
email = address.email.strip().lower()
verified = bool(address.verified)
break
if not email:
extra = sociallogin.account.extra_data or {}
email = (extra.get("email") or "").strip().lower()
verified = bool(extra.get("email_verified", False))
return email, verified


class NoLocalSignupAccountAdapter(DefaultAccountAdapter):
"""Disable allauth's own username/password signup entirely.

LibrePhotos owns local registration; allauth is present purely to broker
OIDC. This closes the door on allauth ever creating a local account.
"""

def is_open_for_signup(self, request):
return False


class SSOSocialAccountAdapter(DefaultSocialAccountAdapter):
def _provisioning_allowed(self):
"""Whether a first-time SSO login may create a new LibrePhotos account."""
return bool(site_config.OIDC_ALLOW_SIGNUP) and email_is_configured()

def is_open_for_signup(self, request, sociallogin):
return self._provisioning_allowed()

def pre_social_login(self, request, sociallogin):
"""Enforce the linking/provisioning policy before allauth commits.

Runs after the IdP round-trip but before a user is logged in or created.
"""
# The social account is already linked to a LibrePhotos user: nothing to
# decide, let the login proceed.
if sociallogin.is_existing:
return

email, verified = _extract_email(sociallogin)

# Try to match an existing account by email. Only an unambiguous,
# non-empty match counts — many LibrePhotos users have a blank email, and
# we must never link a social identity to the wrong (or every) account.
existing = None
if email:
matches = list(User.objects.filter(email__iexact=email))
if len(matches) == 1:
existing = matches[0]

if existing is not None:
if not verified:
# Account-takeover guard: refuse to attach an unverified IdP
# email to an existing local account.
raise _login_error_redirect("email_not_verified")
sociallogin.connect(request, existing)
return

# No existing account -> this would be a brand-new signup.
if not self._provisioning_allowed():
raise _login_error_redirect("signup_disabled")
if not verified:
raise _login_error_redirect("email_not_verified")
# Fall through: allauth proceeds to save_user() below.

def save_user(self, request, sociallogin, form=None):
"""Create the LibrePhotos account, forced non-privileged.

Even during first-time setup (no superuser yet) an SSO-provisioned user
must never become a superuser or staff member.
"""
user = super().save_user(request, sociallogin, form=form)
if user.is_superuser or user.is_staff:
user.is_superuser = False
user.is_staff = False
user.save(update_fields=["is_superuser", "is_staff"])
return user
25 changes: 25 additions & 0 deletions apps/backend/api/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,28 @@ def ready(self):
# ready() runs in every worker and management command: failing to
# adjust a rotation setting is never worth refusing to start over.
logger.exception("Unexpected error while configuring logging")

self._install_oidc_adapter()

def _install_oidc_adapter(self):
"""Point allauth's OIDC provider at our callback-URL-aware adapter.

``oauth2_adapter_class`` is the attribute allauth expects a provider to
set in order to supply its own adapter, but the generic OIDC provider is
instantiated per ``SocialApp`` rather than subclassed, so there is no
subclass of our own to declare it on — hence assigning it here.

This has to happen at the provider level rather than in the view: the
login redirect is built by ``OAuth2Provider.redirect()``, which
constructs a *fresh* adapter from this attribute and ignores whatever
instance a view was handed. Overriding only the view would leave the
login redirect using allauth's callback URL and the token exchange using
ours, and OIDC requires the two to be byte-identical.
"""
from allauth.socialaccount.providers.openid_connect.provider import (
OpenIDConnectProvider,
)

from api.views.sso import LibrePhotosOIDCAdapter

OpenIDConnectProvider.oauth2_adapter_class = LibrePhotosOIDCAdapter
53 changes: 53 additions & 0 deletions apps/backend/api/public_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Working out the public, browser-reachable base URL of this installation.

The reverse proxy forwards ``/api/`` and ``/media/`` to the backend with the
Host header rewritten to ``backend`` (see deploy/docker/proxy/nginx.conf), which
is deliberate: ``ALLOWED_HOSTS`` only lists ``localhost`` and ``BACKEND_HOST``, so
Django's host validation passes no matter what domain the user actually browses
to. The cost is that ``request.build_absolute_uri()`` on an ``/api/`` request
yields ``http://backend/...`` — a name only resolvable inside the Docker network.

So anything that hands a URL to the outside world (a link in an email, an OAuth
``redirect_uri`` the browser must follow) has to be told the public origin rather
than inferring it from the request.
"""

import os

from django.conf import settings


def _internal_hosts():
"""Host names the proxy substitutes for ``/api/`` requests.

A base URL pointing at one of these is reachable only from inside the Docker
network, so it cannot be handed to a browser. ``localhost`` is deliberately
*not* included: for a single-machine install it is a perfectly good public
origin.
"""
return {"backend", os.environ.get("BACKEND_HOST", "backend")}


def public_base_url(request=None):
"""Return the public base URL (no trailing slash), or ``""`` if unknown.

Prefers the explicitly configured ``FRONTEND_BASE_URL``. Falls back to the
origin the request arrived on, which is correct for a direct-to-Django
deployment and wrong behind the standard proxy — hence the caller-visible
empty-string case for "could not determine a public URL".
"""
configured = (settings.FRONTEND_BASE_URL or "").rstrip("/")
if configured:
return configured
if request is None:
return ""
return request.build_absolute_uri("/").rstrip("/")


def is_internal_base_url(base_url):
"""Whether ``base_url`` points somewhere only the Docker network can reach."""
if not base_url:
return True
without_scheme = base_url.split("://", 1)[-1]
host = without_scheme.split("/", 1)[0].split(":", 1)[0]
return host in _internal_hosts()
Loading
Loading