Fixes [REQUEST] Email-based auto-linking for OIDC

Fixes #921
This commit is contained in:
Sean Morley
2025-12-13 10:41:37 -05:00
parent d954ac057d
commit 725fec30eb
5 changed files with 127 additions and 50 deletions

View File

@@ -27,6 +27,8 @@ EMAIL_BACKEND='console'
# GOOGLE_MAPS_API_KEY='key'
# ACCOUNT_EMAIL_VERIFICATION='none' # 'none', 'optional', 'mandatory' # You can change this as needed for your environment
# ------------------- #
# For Developers to start a Demo Database

View File

@@ -1,11 +1,8 @@
"""
Django settings for demo project.
AdventureLog Server settings
For more information on this file, see
https://docs.djangoproject.com/en/1.7/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.7/ref/settings/
Reference:
- Django settings: https://docs.djangoproject.com/en/stable/ref/settings/
"""
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
@@ -15,14 +12,21 @@ from os import getenv
from pathlib import Path
from urllib.parse import urlparse
from publicsuffix2 import get_sld
# Load environment variables from .env file
# ---------------------------------------------------------------------------
# Environment & Paths
# ---------------------------------------------------------------------------
# Load environment variables from .env file early so getenv works everywhere.
load_dotenv()
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/
# See Django deployment checklist for production hardening.
# ---------------------------------------------------------------------------
# Core Security & Debug
# ---------------------------------------------------------------------------
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = getenv('SECRET_KEY')
@@ -34,8 +38,11 @@ DEBUG = getenv('DEBUG', 'true').lower() == 'true'
# '127.0.0.1',
# 'server'
# ]
ALLOWED_HOSTS = ['*']
ALLOWED_HOSTS = ['*'] # In production, restrict to known hosts.
# ---------------------------------------------------------------------------
# Installed Apps
# ---------------------------------------------------------------------------
INSTALLED_APPS = (
'django.contrib.admin',
'django.contrib.auth',
@@ -67,6 +74,9 @@ INSTALLED_APPS = (
)
# ---------------------------------------------------------------------------
# Middleware
# ---------------------------------------------------------------------------
MIDDLEWARE = (
'whitenoise.middleware.WhiteNoiseMiddleware',
'adventures.middleware.XSessionTokenMiddleware',
@@ -83,9 +93,9 @@ MIDDLEWARE = (
'allauth.account.middleware.AccountMiddleware',
)
# disable verifications for new users
ACCOUNT_EMAIL_VERIFICATION = 'none'
# ---------------------------------------------------------------------------
# Caching
# ---------------------------------------------------------------------------
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.memcached.PyMemcacheCache',
@@ -101,9 +111,9 @@ ROOT_URLCONF = 'main.urls'
# WSGI_APPLICATION = 'demo.wsgi.application'
# ---------------------------------------------------------------------------
# Database
# https://docs.djangoproject.com/en/1.7/ref/settings/#databases
# ---------------------------------------------------------------------------
# Using legacy PG environment variables for compatibility with existing setups
def env(*keys, default=None):
@@ -131,58 +141,52 @@ DATABASES = {
# Internationalization
# https://docs.djangoproject.com/en/1.7/topics/i18n/
# ---------------------------------------------------------------------------
# Internationalization
# ---------------------------------------------------------------------------
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# ---------------------------------------------------------------------------
# Frontend URL & Cookies
# ---------------------------------------------------------------------------
# Derive frontend URL from environment and configure cookie behavior.
unParsedFrontenedUrl = getenv('FRONTEND_URL', 'http://localhost:3000')
FRONTEND_URL = unParsedFrontenedUrl.translate(str.maketrans('', '', '\'"'))
SESSION_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_NAME = 'sessionid'
# Secure cookies if frontend is served over HTTPS
SESSION_COOKIE_SECURE = FRONTEND_URL.startswith('https')
CSRF_COOKIE_SECURE = FRONTEND_URL.startswith('https')
# Dynamically determine cookie domain to support subdomains while avoiding IPs
hostname = urlparse(FRONTEND_URL).hostname
is_ip_address = hostname.replace('.', '').isdigit()
# Check if the hostname is single-label (no dots)
is_single_label = '.' not in hostname
is_single_label = '.' not in hostname # single-label hostnames (e.g., "localhost")
if is_ip_address or is_single_label:
# Do not set a domain for IP addresses or single-label hostnames
SESSION_COOKIE_DOMAIN = None
else:
# Use publicsuffix2 to calculate the correct cookie domain
cookie_domain = get_sld(hostname)
if cookie_domain:
SESSION_COOKIE_DOMAIN = f".{cookie_domain}"
else:
# Fallback to the hostname if parsing fails
SESSION_COOKIE_DOMAIN = hostname
SESSION_COOKIE_DOMAIN = f".{cookie_domain}" if cookie_domain else hostname
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.7/howto/static-files/
# ---------------------------------------------------------------------------
# Static & Media Files
# ---------------------------------------------------------------------------
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
BASE_DIR = Path(__file__).resolve().parent.parent
STATIC_ROOT = BASE_DIR / "staticfiles"
STATIC_URL = '/static/'
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media' # This path must match the NGINX root
MEDIA_ROOT = BASE_DIR / 'media' # Must match NGINX root for media serving
STATICFILES_DIRS = [BASE_DIR / 'static']
STORAGES = {
@@ -196,6 +200,9 @@ STORAGES = {
SILENCED_SYSTEM_CHECKS = ["slippers.E001"]
# ---------------------------------------------------------------------------
# Templates
# ---------------------------------------------------------------------------
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
@@ -212,21 +219,18 @@ TEMPLATES = [
},
]
# Authentication settings
# ---------------------------------------------------------------------------
# Authentication & Accounts
# ---------------------------------------------------------------------------
DISABLE_REGISTRATION = getenv('DISABLE_REGISTRATION', 'false').lower() == 'true'
DISABLE_REGISTRATION_MESSAGE = getenv('DISABLE_REGISTRATION_MESSAGE', 'Registration is disabled. Please contact the administrator if you need an account.')
AUTH_USER_MODEL = 'users.CustomUser'
ACCOUNT_ADAPTER = 'users.adapters.NoNewUsersAccountAdapter'
ACCOUNT_SIGNUP_FORM_CLASS = 'users.form_overrides.CustomSignupForm'
SESSION_SAVE_EVERY_REQUEST = True
# Set login redirect URL to the frontend
LOGIN_REDIRECT_URL = FRONTEND_URL
LOGIN_REDIRECT_URL = FRONTEND_URL # Redirect to frontend after login
SOCIALACCOUNT_LOGIN_ON_GET = True
@@ -235,8 +239,7 @@ HEADLESS_FRONTEND_URLS = {
"account_reset_password": f"{FRONTEND_URL}/user/reset-password",
"account_reset_password_from_key": f"{FRONTEND_URL}/user/reset-password/{{key}}",
"account_signup": f"{FRONTEND_URL}/signup",
# Fallback in case the state containing the `next` URL is lost and the handshake
# with the third-party provider fails.
# Fallback if handshake with provider fails and `next` URL is lost.
"socialaccount_login_error": f"{FRONTEND_URL}/account/provider/callback",
}
@@ -249,8 +252,12 @@ AUTHENTICATION_BACKENDS = [
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
SITE_ID = 1
ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_AUTHENTICATION_METHOD = 'username'
ACCOUNT_EMAIL_VERIFICATION = 'optional'
ACCOUNT_UNIQUE_EMAIL = True
ACCOUNT_EMAIL_VERIFICATION = getenv('ACCOUNT_EMAIL_VERIFICATION', 'none') # 'none', 'optional', 'mandatory'
SOCIALACCOUNT_EMAIL_AUTHENTICATION = True
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True # Auto-link by email
SOCIALACCOUNT_AUTO_SIGNUP = True # Allow auto-signup post adapter checks
if getenv('EMAIL_BACKEND', 'console') == 'console':
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
@@ -274,6 +281,9 @@ else:
# DEFAULT_FROM_EMAIL = 'mail@mail.user.com'
# ---------------------------------------------------------------------------
# Django REST Framework
# ---------------------------------------------------------------------------
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.SessionAuthentication',
@@ -292,15 +302,18 @@ else:
)
# ---------------------------------------------------------------------------
# CORS & CSRF
# ---------------------------------------------------------------------------
CORS_ALLOWED_ORIGINS = [origin.strip() for origin in getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost').split(',') if origin.strip()]
CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost').split(',') if origin.strip()]
CORS_ALLOW_CREDENTIALS = True
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
@@ -326,6 +339,9 @@ LOGGING = {
},
}
# ---------------------------------------------------------------------------
# Public URLs & Third-Party Integrations
# ---------------------------------------------------------------------------
PUBLIC_URL = getenv('PUBLIC_URL', 'http://localhost:8000')
# ADVENTURELOG_CDN_URL = getenv('ADVENTURELOG_CDN_URL', 'https://cdn.adventurelog.app')
@@ -336,7 +352,7 @@ ADVENTURELOG_RELEASE_VERSION = 'v0.11.0'
# https://github.com/dr5hn/countries-states-cities-database/tags
COUNTRY_REGION_JSON_VERSION = 'v2.6'
# External service keys (do not hardcode secrets)
GOOGLE_MAPS_API_KEY = getenv('GOOGLE_MAPS_API_KEY', '')
STRAVA_CLIENT_ID = getenv('STRAVA_CLIENT_ID', '')
STRAVA_CLIENT_SECRET = getenv('STRAVA_CLIENT_SECRET', '')

View File

@@ -4,3 +4,8 @@ from django.apps import AppConfig
class UsersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'users'
def ready(self):
# Ensure signal handlers are registered
import users.signals # noqa: F401

View File

@@ -0,0 +1,53 @@
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractUser
from allauth.account.models import EmailAddress
from django.db.models.signals import pre_delete
User = get_user_model()
def _sync_user_email(user: AbstractUser):
from allauth.account.models import EmailAddress # local import to avoid early import issues
# Prefer the primary email if it exists
primary = EmailAddress.objects.filter(user=user, primary=True).first()
if primary:
if user.email != primary.email:
user.email = primary.email
user.save(update_fields=['email'])
return
# Fallback: if any email exists, use the first; if none, leave user.email unchanged
any_email = EmailAddress.objects.filter(user=user).order_by('id').first()
if any_email and user.email != any_email.email:
user.email = any_email.email
user.save(update_fields=['email'])
@receiver(post_save)
def emailaddress_post_save(sender, instance, **kwargs):
# Only react to allauth EmailAddress saves
try:
from allauth.account.models import EmailAddress
except Exception:
return
if sender is EmailAddress:
_sync_user_email(instance.user)
@receiver(post_delete)
def emailaddress_post_delete(sender, instance, **kwargs):
# Only react to allauth EmailAddress deletes
try:
from allauth.account.models import EmailAddress
except Exception:
return
if sender is EmailAddress:
_sync_user_email(instance.user)
# Prevent deleting the last email address for a user
@receiver(pre_delete, sender=EmailAddress)
def prevent_deleting_last_email(sender, instance, using, **kwargs):
user = instance.user
email_count = EmailAddress.objects.filter(user=user).count()
if email_count <= 1:
raise ValueError("Cannot delete the last email address of a user.")

View File

@@ -864,6 +864,7 @@
<button
class="btn btn-sm btn-warning"
on:click={() => removeEmail(email)}
disabled={emails.length === 1 || email.primary}
>
{$t('adventures.remove')}
</button>