Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91d150538e | |||
| 02f4a38ba3 | |||
| 9e4d3698b1 | |||
| 0f3f0680f6 | |||
| 31d6e1b6b0 | |||
| 166835c2a9 | |||
| b165f4af38 | |||
| fea1a68edf | |||
| ed4618c4d0 | |||
| 5a9cab4431 | |||
| e18807f8f8 | |||
| 03ddc93780 | |||
| 3041786644 | |||
| 2b6a8820e0 | |||
| 73da1bcf85 | |||
| 543ff3c5cc | |||
| 1ada8e4fa2 | |||
| c0acced574 | |||
| 47bcc0c33c | |||
| ab900e287b | |||
| be1897645b | |||
| 5b4e0354ca | |||
| 60f61bf90a | |||
| c49c2cfba3 | |||
| 9647226876 | |||
| 0ffecb9100 | |||
| fc82767666 |
12
.env
12
.env
@ -21,3 +21,15 @@ ADMIN_PASSWORD=1993llelMO65026illustrations
|
|||||||
IMPORT_SEED_ON_START=false
|
IMPORT_SEED_ON_START=false
|
||||||
# SEED_CSV=/data/imports/illustrations_seed.csv
|
# SEED_CSV=/data/imports/illustrations_seed.csv
|
||||||
OPENAI_API_KEY=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
|
OPENAI_API_KEY=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
|
||||||
|
|
||||||
|
# Authentik OIDC
|
||||||
|
OIDC_RP_CLIENT_ID=yQnn1VzxZh7hglqCWuBX4ekwpuvDgxss0IhDuULx
|
||||||
|
OIDC_RP_CLIENT_SECRET=LmWIlfXJMGMPbc4NKOkMFZ5gxKRpvHykkeMe7OQ1ugykihVa9yPlDQl4hPbfvKiYoj5Nm2ONU7RkQGSWovmS2dLUjGhYUxv4JgSzK0s2ECdHmUn1QH6iVaNncWn3zZMb
|
||||||
|
|
||||||
|
# Endpoints (see next section)
|
||||||
|
OIDC_OP_AUTHORIZATION_ENDPOINT=https://auth.lakehandyman.biz/application/o/authorize/
|
||||||
|
OIDC_OP_TOKEN_ENDPOINT=https://auth.lakehandyman.biz/application/o/token/
|
||||||
|
OIDC_OP_USER_ENDPOINT=https://auth.lakehandyman.biz/application/o/userinfo/
|
||||||
|
OIDC_OP_JWKS_ENDPOINT=https://auth.lakehandyman.biz/application/o/illustration-database/jwks/
|
||||||
|
|
||||||
|
OIDC_RP_SCOPES=openid email profile
|
||||||
|
|||||||
68
web/core/auth_oidc.py
Normal file
68
web/core/auth_oidc.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
|
||||||
|
|
||||||
|
|
||||||
|
class AuthentikOIDCBackend(OIDCAuthenticationBackend):
|
||||||
|
"""
|
||||||
|
Custom backend to integrate Authentik OIDC with existing Django users
|
||||||
|
without breaking local authentication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def filter_users_by_claims(self, claims):
|
||||||
|
email = (claims.get("email") or "").strip().lower()
|
||||||
|
username = (
|
||||||
|
claims.get("preferred_username")
|
||||||
|
or claims.get("username")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
# 1) Prefer matching by email
|
||||||
|
if email:
|
||||||
|
qs = self.UserModel.objects.filter(email__iexact=email)
|
||||||
|
if qs.exists():
|
||||||
|
return qs
|
||||||
|
|
||||||
|
# 2) Fallback to username match (critical for existing local users)
|
||||||
|
if username:
|
||||||
|
qs = self.UserModel.objects.filter(username__iexact=username)
|
||||||
|
if qs.exists():
|
||||||
|
return qs
|
||||||
|
|
||||||
|
# 3) Otherwise, no match -> create user
|
||||||
|
return self.UserModel.objects.none()
|
||||||
|
|
||||||
|
def create_user(self, claims):
|
||||||
|
user = super().create_user(claims)
|
||||||
|
|
||||||
|
email = (claims.get("email") or "").strip().lower()
|
||||||
|
username = (
|
||||||
|
claims.get("preferred_username")
|
||||||
|
or claims.get("username")
|
||||||
|
or email
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
if email:
|
||||||
|
user.email = email
|
||||||
|
|
||||||
|
# Only set username if Django hasn't already set one
|
||||||
|
if username and not user.username:
|
||||||
|
user.username = username
|
||||||
|
|
||||||
|
user.first_name = claims.get("given_name", "") or user.first_name
|
||||||
|
user.last_name = claims.get("family_name", "") or user.last_name
|
||||||
|
|
||||||
|
# SSO-only account unless you explicitly add a password later
|
||||||
|
user.set_unusable_password()
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
def update_user(self, user, claims):
|
||||||
|
email = (claims.get("email") or "").strip().lower()
|
||||||
|
if email and user.email.lower() != email:
|
||||||
|
user.email = email
|
||||||
|
|
||||||
|
user.first_name = claims.get("given_name", "") or user.first_name
|
||||||
|
user.last_name = claims.get("family_name", "") or user.last_name
|
||||||
|
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
@ -53,15 +53,22 @@ EXPECTED_HEADERS = [
|
|||||||
"Date Edited",
|
"Date Edited",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def is_admin(user):
|
|
||||||
return user.is_superuser or user.is_staff
|
|
||||||
|
|
||||||
|
|
||||||
def login_view(request):
|
def login_view(request):
|
||||||
|
# If Django session already exists, go to app
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
return redirect("search")
|
return redirect("search")
|
||||||
|
|
||||||
|
# Only auto-start OIDC if this is a fresh browser visit
|
||||||
|
# and NOT a redirect coming from Django itself
|
||||||
|
if (
|
||||||
|
request.method == "GET"
|
||||||
|
and "next" not in request.GET
|
||||||
|
):
|
||||||
|
return redirect("oidc_authentication_init")
|
||||||
|
|
||||||
|
# Fallback: show login page (rare, but prevents loops)
|
||||||
ctx = {}
|
ctx = {}
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
u = request.POST.get("username")
|
u = request.POST.get("username")
|
||||||
p = request.POST.get("password")
|
p = request.POST.get("password")
|
||||||
@ -70,9 +77,13 @@ def login_view(request):
|
|||||||
login(request, user)
|
login(request, user)
|
||||||
return redirect("search")
|
return redirect("search")
|
||||||
ctx["error"] = "Invalid credentials"
|
ctx["error"] = "Invalid credentials"
|
||||||
|
|
||||||
return render(request, "login.html", ctx)
|
return render(request, "login.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin(user):
|
||||||
|
return user.is_superuser or user.is_staff
|
||||||
|
|
||||||
def entry_context(entry, result_ids):
|
def entry_context(entry, result_ids):
|
||||||
"""
|
"""
|
||||||
Build the navigation + chips context for the entry pages.
|
Build the navigation + chips context for the entry pages.
|
||||||
|
|||||||
@ -12,7 +12,7 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
|||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
"django.contrib.admin","django.contrib.auth","django.contrib.contenttypes",
|
"django.contrib.admin","django.contrib.auth","django.contrib.contenttypes",
|
||||||
"django.contrib.sessions","django.contrib.messages","django.contrib.staticfiles",
|
"django.contrib.sessions","django.contrib.messages","django.contrib.staticfiles",
|
||||||
"core",
|
"core","mozilla_django_oidc",
|
||||||
]
|
]
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
@ -52,6 +52,12 @@ DATABASES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AUTHENTICATION_BACKENDS = (
|
||||||
|
"core.auth_oidc.AuthentikOIDCBackend", # OIDC via Authentik
|
||||||
|
"django.contrib.auth.backends.ModelBackend", # keep existing username/password login
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
LANGUAGE_CODE="en-us"
|
LANGUAGE_CODE="en-us"
|
||||||
TIME_ZONE="America/Chicago"
|
TIME_ZONE="America/Chicago"
|
||||||
USE_I18N=True
|
USE_I18N=True
|
||||||
@ -66,6 +72,22 @@ LOGIN_REDIRECT_URL="/search/"
|
|||||||
LOGOUT_REDIRECT_URL="/login/"
|
LOGOUT_REDIRECT_URL="/login/"
|
||||||
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
|
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
|
||||||
|
|
||||||
|
# --- Authentik OIDC ---
|
||||||
|
OIDC_RP_CLIENT_ID = os.getenv("OIDC_RP_CLIENT_ID", "")
|
||||||
|
OIDC_RP_CLIENT_SECRET = os.getenv("OIDC_RP_CLIENT_SECRET", "")
|
||||||
|
|
||||||
|
OIDC_OP_AUTHORIZATION_ENDPOINT = os.getenv("OIDC_OP_AUTHORIZATION_ENDPOINT", "")
|
||||||
|
OIDC_OP_TOKEN_ENDPOINT = os.getenv("OIDC_OP_TOKEN_ENDPOINT", "")
|
||||||
|
OIDC_OP_USER_ENDPOINT = os.getenv("OIDC_OP_USER_ENDPOINT", "")
|
||||||
|
OIDC_OP_JWKS_ENDPOINT = os.getenv("OIDC_OP_JWKS_ENDPOINT", "")
|
||||||
|
|
||||||
|
OIDC_RP_SCOPES = os.getenv("OIDC_RP_SCOPES", "openid email profile")
|
||||||
|
OIDC_CREATE_USER = True
|
||||||
|
USE_X_FORWARDED_HOST = True
|
||||||
|
OIDC_RP_SIGN_ALGO = "RS256"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
||||||
# Ensure MEDIA_ROOT exists (you likely already have this)
|
# Ensure MEDIA_ROOT exists (you likely already have this)
|
||||||
|
|||||||
@ -42,6 +42,7 @@ urlpatterns = [
|
|||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
path("login/", core_views.login_view, name="login"),
|
path("login/", core_views.login_view, name="login"),
|
||||||
|
path("oidc/", include("mozilla_django_oidc.urls")),
|
||||||
path("logout/", auth_views.LogoutView.as_view(next_page="/login/"), name="logout"),
|
path("logout/", auth_views.LogoutView.as_view(next_page="/login/"), name="logout"),
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
|
|
||||||
|
|||||||
@ -2,3 +2,4 @@ Django==5.0.6
|
|||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
python-dateutil==2.9.0.post0
|
python-dateutil==2.9.0.post0
|
||||||
openai>=1.0,<2.0
|
openai>=1.0,<2.0
|
||||||
|
mozilla-django-oidc==5.0.2
|
||||||
|
|||||||
@ -5,6 +5,6 @@
|
|||||||
"fg", "fy", "gt", "hb", "im", "ip", "it", "jv", "ka", "kj", "kl",
|
"fg", "fy", "gt", "hb", "im", "ip", "it", "jv", "ka", "kj", "kl",
|
||||||
"lf", "lff", "ll", "ly", "my", "od", "pe", "po", "pt", "rr", "rs",
|
"lf", "lff", "ll", "ly", "my", "od", "pe", "po", "pt", "rr", "rs",
|
||||||
"sg", "sh", "si", "td", "tp", "tr", "ts", "un", "jy",
|
"sg", "sh", "si", "td", "tp", "tr", "ts", "un", "jy",
|
||||||
"uw", "su", "re", "lvs", "lp", "yy", "yp2", "yp", "sl", "pm", "kc", "jd", "jr", "ia", "hs", "ia", "hs", "lv", "kr", "km", "wcg", "bw", "ce", "ad"
|
"uw", "su", "re", "lvs", "lp", "yy", "yp2", "yp", "sl", "pm", "kc"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -3,48 +3,93 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Sign in · Illustrations</title>
|
<title>Illustrations · Sign in</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="stylesheet" href="{% static 'app.css' %}">
|
<link rel="stylesheet" href="{% static 'app.css' %}">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="login-page">
|
<body class="login-page">
|
||||||
|
|
||||||
<div class="login-hero">
|
<div class="login-hero">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<h1 class="login-title">Sign in</h1>
|
|
||||||
|
<!-- App title instead of generic "Sign in" -->
|
||||||
|
<h1 class="login-title">Illustrations Database Login</h1>
|
||||||
|
|
||||||
|
<div class="login-sso">
|
||||||
|
<div class="login-box">
|
||||||
|
<a href="{% url 'oidc_authentication_init' %}"
|
||||||
|
class="btn btn-primary btn-lg login-sso-button">
|
||||||
|
Log in with SSO
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legacy toggle -->
|
||||||
|
<div class="login-divider" style="margin-top: 1.25rem;">
|
||||||
|
<span>
|
||||||
|
<a href="#"
|
||||||
|
onclick="event.preventDefault(); document.getElementById('alt-login').toggleAttribute('hidden');">
|
||||||
|
Use a different sign-in method
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legacy login (hidden by default) -->
|
||||||
|
<div id="alt-login" hidden>
|
||||||
|
|
||||||
{% if form.non_field_errors %}
|
{% if form.non_field_errors %}
|
||||||
<div class="login-alert">
|
<div class="login-alert">
|
||||||
{% for e in form.non_field_errors %}{{ e }}{% if not forloop.last %}<br>{% endif %}{% endfor %}
|
{% for e in form.non_field_errors %}
|
||||||
|
{{ e }}{% if not forloop.last %}<br>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="{% url 'login' %}" novalidate>
|
<form method="post" action="{% url 'login' %}" novalidate>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
|
{% if next %}
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<label for="id_username" class="login-label">Username</label>
|
<label for="id_username" class="login-label">Username</label>
|
||||||
<input id="id_username" name="username" type="text" autocomplete="username"
|
<input id="id_username"
|
||||||
value="{{ form.username.value|default:'' }}" required autofocus class="login-input">
|
name="username"
|
||||||
|
type="text"
|
||||||
|
autocomplete="username"
|
||||||
|
value="{{ form.username.value|default:'' }}"
|
||||||
|
class="login-input">
|
||||||
|
|
||||||
{% if form.username.errors %}
|
{% if form.username.errors %}
|
||||||
<div class="login-field-error">
|
<div class="login-field-error">
|
||||||
{% for e in form.username.errors %}{{ e }}{% if not forloop.last %}<br>{% endif %}{% endfor %}
|
{% for e in form.username.errors %}
|
||||||
|
{{ e }}{% if not forloop.last %}<br>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<label for="id_password" class="login-label">Password</label>
|
<label for="id_password" class="login-label">Password</label>
|
||||||
<input id="id_password" name="password" type="password" autocomplete="current-password"
|
<input id="id_password"
|
||||||
required class="login-input">
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="login-input">
|
||||||
|
|
||||||
{% if form.password.errors %}
|
{% if form.password.errors %}
|
||||||
<div class="login-field-error">
|
<div class="login-field-error">
|
||||||
{% for e in form.password.errors %}{{ e }}{% if not forloop.last %}<br>{% endif %}{% endfor %}
|
{% for e in form.password.errors %}
|
||||||
|
{{ e }}{% if not forloop.last %}<br>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<button class="btn btn-primary btn-lg login-submit" type="submit">Sign in</button>
|
<button class="btn btn-primary btn-lg login-submit" type="submit">
|
||||||
|
Log in
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,8 @@
|
|||||||
<button class="btn btn-primary">Search</button>
|
<button class="btn btn-primary">Search</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-row">
|
<!-- Desktop filters -->
|
||||||
|
<div class="filter-row desktop-filters">
|
||||||
{% for f in field_options %}
|
{% for f in field_options %}
|
||||||
<label class="check-pill">
|
<label class="check-pill">
|
||||||
<input type="checkbox" name="{{ f.name }}" {% if f.checked %}checked{% endif %}>
|
<input type="checkbox" name="{{ f.name }}" {% if f.checked %}checked{% endif %}>
|
||||||
@ -19,6 +20,19 @@
|
|||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile dropdown filters -->
|
||||||
|
<div class="filter-row mobile-filters">
|
||||||
|
<button type="button" id="filterDropdownBtn" class="btn btn-secondary" aria-expanded="false">Filters ▾</button>
|
||||||
|
<div id="filterDropdownPanel" class="dropdown-panel">
|
||||||
|
{% for f in field_options %}
|
||||||
|
<label class="check-pill" style="display:block; margin:6px 0;">
|
||||||
|
<input type="checkbox" name="{{ f.name }}" {% if f.checked %}checked{% endif %}>
|
||||||
|
<span>{{ f.label }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if ran_search and result_count == 0 %}
|
{% if ran_search and result_count == 0 %}
|
||||||
@ -197,6 +211,17 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mobile filter dropdown toggle
|
||||||
|
(function(){
|
||||||
|
const btn = document.getElementById('filterDropdownBtn');
|
||||||
|
const panel = document.getElementById('filterDropdownPanel');
|
||||||
|
if (!btn || !panel) return;
|
||||||
|
btn.addEventListener('click', ()=>{
|
||||||
|
const open = panel.classList.toggle('open');
|
||||||
|
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
// ===============================
|
// ===============================
|
||||||
// No-results: show a random funny illustration
|
// No-results: show a random funny illustration
|
||||||
// ===============================
|
// ===============================
|
||||||
@ -342,6 +367,21 @@
|
|||||||
padding:10px 16px;
|
padding:10px 16px;
|
||||||
margin:0;
|
margin:0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile filter dropdown styling */
|
||||||
|
.mobile-filters { display:none; margin-top:10px; }
|
||||||
|
@media (max-width: 700px){
|
||||||
|
.desktop-filters { display:none; }
|
||||||
|
.mobile-filters { display:block; }
|
||||||
|
#filterDropdownPanel {
|
||||||
|
border:1px solid var(--border);
|
||||||
|
background:#fff;
|
||||||
|
border-radius:10px;
|
||||||
|
margin-top:6px;
|
||||||
|
padding:10px;
|
||||||
|
}
|
||||||
|
#filterDropdownBtn { width:100%; text-align:left; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- Save q + selected fields exactly as submitted -->
|
<!-- Save q + selected fields exactly as submitted -->
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user