GitHub Social Login + Auth flow fixes. Password reset, email verification, etc.

This commit is contained in:
boris committed 2026-04-16 21:24:16 +01:00
1 parent f7cf22f970
commit 921b760f80
23 files changed
+1079 -220

No files matched your search

+17 -7
View File
@@ -4,6 +4,7 @@ KEYWARDEN_DEBUG=True
KEYWARDEN_ALLOWED_HOSTS=*
KEYWARDEN_TRUSTED_ORIGINS=https://reverse.proxy.domain.xyz,https://127.0.0.1
KEYWARDEN_DOMAIN=https://example.domain.xyz
KEYWARDEN_IDP_ACCOUNT_PORTAL_URL=
KEYWARDEN_SHELL_REMOTE_COMMAND=/bin/bash
# Set to 1 to include SSH probe/certificate diagnostics in shell failures.
KEYWARDEN_SHELL_DEBUG=0
@@ -33,6 +34,22 @@ KEYWARDEN_ADMIN_PASSWORD=password
KEYWARDEN_AUTH_MODE=native
# Email delivery (required for password reset + native email verification)
# KEYWARDEN_EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
# KEYWARDEN_EMAIL_HOST=smtp.example.com
# KEYWARDEN_EMAIL_PORT=587
# KEYWARDEN_EMAIL_HOST_USER=
# KEYWARDEN_EMAIL_HOST_PASSWORD=
# KEYWARDEN_EMAIL_USE_TLS=true
# KEYWARDEN_EMAIL_USE_SSL=false
# KEYWARDEN_DEFAULT_FROM_EMAIL=keywarden@example.com
# KEYWARDEN_SERVER_EMAIL=keywarden@example.com
# Native email verification
# KEYWARDEN_EMAIL_VERIFICATION_ENABLED=true
# KEYWARDEN_EMAIL_VERIFICATION_TOKEN_MAX_AGE_SECONDS=86400
# KEYWARDEN_EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS=300
# OIDC (optional) Currently supports Authentik officially and tested. Other solutions such as Keycloak may work.
# KEYWARDEN_OIDC_CLIENT_ID=
@@ -58,16 +75,9 @@ KEYWARDEN_AUTH_MODE=native
# Social login (optional)
# KEYWARDEN_SITE_ID=1
# KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL=true
# KEYWARDEN_SOCIAL_ALLOW_AUTO_ONBOARDING=false
# KEYWARDEN_SOCIAL_GOOGLE_ENABLED=false
# KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID=
# KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET=
# KEYWARDEN_SOCIAL_GITHUB_ENABLED=false
# KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID=
# KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET=
# KEYWARDEN_SOCIAL_APPLE_ENABLED=false
# KEYWARDEN_SOCIAL_APPLE_CLIENT_ID=
# KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET=
# Authentik Secrets
AUTHENTIK_PG_PASS=
+12 -15
View File
@@ -2,7 +2,7 @@
This guide configures:
- OIDC client login (Authentik, Authelia, Keycloak, or other OIDC IdP)
- Social login (Google, GitHub, Apple)
- Social login (GitHub)
Security model implemented by Keywarden:
- External identities are mapped by immutable provider subject (`sub`/`uid`) and provider ID.
@@ -120,25 +120,17 @@ KEYWARDEN_OIDC_ISSUER=https://id.example.com/realms/main
KEYWARDEN_OIDC_GROUPS_CLAIM=groups
```
## 5. Social providers
## 5. GitHub social provider
Enable providers by credentials (or explicit `*_ENABLED=true`).
Enable GitHub by credentials (or explicit `*_ENABLED=true`).
```env
KEYWARDEN_SITE_ID=1
KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL=true
KEYWARDEN_SOCIAL_GOOGLE_ENABLED=true
KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID=<google-client-id>
KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET=<google-secret>
KEYWARDEN_SOCIAL_GITHUB_ENABLED=true
KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID=<github-client-id>
KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET=<github-secret>
KEYWARDEN_SOCIAL_APPLE_ENABLED=true
KEYWARDEN_SOCIAL_APPLE_CLIENT_ID=<apple-services-id>
KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET=<apple-client-secret-jwt>
```
Keywarden includes social auth URLs under:
@@ -147,10 +139,15 @@ Keywarden includes social auth URLs under:
/accounts/sso/
```
Provider callback URLs to register:
- Google: `https://<your-keywarden-domain>/accounts/sso/google/login/callback/`
Provider callback URL to register:
- GitHub: `https://<your-keywarden-domain>/accounts/sso/github/login/callback/`
- Apple: `https://<your-keywarden-domain>/accounts/sso/apple/login/callback/`
Security behavior:
- Social auto-onboarding is disabled.
- Users must already exist in Keywarden and must link GitHub from `/accounts/profile/`.
- After linking, `Login with GitHub` is available as an additional login method.
- Local Keywarden email and GitHub email may differ; matching is by linked GitHub subject (`uid`).
- Native password reset/email verification are disabled for SSO-linked accounts and handled in the IdP.
## 6. Email immutability behavior
@@ -180,6 +177,6 @@ When enabled, a previously promoted user is demoted to standard user if admin gr
- Login page shows social buttons for enabled providers.
- `/oidc/authenticate/` starts IdP login.
- First login auto-creates local user.
- First GitHub login only succeeds for previously linked users.
- Repeat logins map to same local user via external identity subject.
- Changing provider email for the same subject causes login rejection.
+18 -1
View File
@@ -3,7 +3,7 @@ from django.contrib import admin
from django.utils import timezone
from unfold.admin import ModelAdmin
from .models import ErasureRequest, ExternalIdentity
from .models import ErasureRequest, ExternalIdentity, NativeAccountSecurity
class ErasureRequestAdminForm(forms.ModelForm):
@@ -89,3 +89,20 @@ class ExternalIdentityAdmin(ModelAdmin):
def has_delete_permission(self, request, obj=None) -> bool:
return False
@admin.register(NativeAccountSecurity)
class NativeAccountSecurityAdmin(ModelAdmin):
list_display = (
"user",
"email_verified",
"email_verified_at",
"verification_email_sent_at",
"updated_at",
)
list_filter = ("email_verified",)
search_fields = ("user__username", "user__email")
readonly_fields = ("user", "email_verified_at", "verification_email_sent_at", "updated_at")
def has_add_permission(self, request) -> bool:
return False
+46
View File
@@ -1,4 +1,50 @@
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm
from .models import ExternalIdentity
class NativePasswordResetForm(PasswordResetForm):
"""Password reset form that excludes SSO-linked accounts."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["email"].widget.attrs.update(
{
"class": "block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500",
"placeholder": "you@example.com",
}
)
def get_users(self, email):
UserModel = get_user_model()
email_field_name = UserModel.get_email_field_name()
users = UserModel._default_manager.filter(
**{
f"{email_field_name}__iexact": email,
"is_active": True,
}
)
for user in users:
email_value = getattr(user, email_field_name, "") or ""
if not user.has_usable_password():
continue
if ExternalIdentity.objects.filter(user=user).exists():
continue
if email.lower() != email_value.lower():
continue
yield user
class StyledSetPasswordForm(SetPasswordForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
field_attrs = {
"class": "block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500",
}
self.fields["new_password1"].widget.attrs.update(field_attrs)
self.fields["new_password2"].widget.attrs.update(field_attrs)
class ErasureRequestForm(forms.Form):
@@ -0,0 +1,35 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("accounts", "0007_externalidentity"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="NativeAccountSecurity",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("email_verified", models.BooleanField(db_index=True, default=False)),
("email_verified_at", models.DateTimeField(blank=True, null=True)),
("verification_email_sent_at", models.DateTimeField(blank=True, null=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="native_account_security",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Native account security",
"verbose_name_plural": "Native account security",
},
),
]
+19
View File
@@ -54,6 +54,25 @@ class ExternalIdentity(models.Model):
)
class NativeAccountSecurity(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="native_account_security",
)
email_verified = models.BooleanField(default=False, db_index=True)
email_verified_at = models.DateTimeField(null=True, blank=True)
verification_email_sent_at = models.DateTimeField(null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Native account security"
verbose_name_plural = "Native account security"
def __str__(self) -> str:
return f"native-security:{self.user_id}:{'verified' if self.email_verified else 'unverified'}"
class ErasureRequest(models.Model):
class Status(models.TextChoices):
PENDING = "pending", "Pending"
+88 -38
View File
@@ -2,16 +2,14 @@ from __future__ import annotations
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.core.exceptions import PermissionDenied
from django.db import IntegrityError
from django.shortcuts import redirect
from django.urls import reverse
from allauth.exceptions import ImmediateHttpResponse
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from apps.core.rbac import ROLE_USER, get_user_role, set_user_role
from .identity import (
build_unique_username,
normalize_email,
@@ -23,8 +21,11 @@ from .models import ExternalIdentity
class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
def pre_social_login(self, request, sociallogin):
provider = str(sociallogin.account.provider or "").strip().lower()
if provider != "github":
self._reject(request, "Only GitHub social authentication is enabled.")
email = self._extract_email(sociallogin)
provider = sociallogin.account.provider
subject = str(sociallogin.account.uid or "").strip()
if not subject:
self._reject(request, "Login failed: provider did not return a stable user id.")
@@ -35,10 +36,13 @@ class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
if not self._email_verified(sociallogin, email):
self._reject(request, "Login failed: provider email is not verified.")
if sociallogin.is_existing:
if normalize_email(sociallogin.user.email) != email:
raise PermissionDenied("Social login email does not match enrolled account email.")
self._sync_identity(sociallogin.user, sociallogin)
if self._is_connect_flow(request, sociallogin):
self._handle_connect_flow(
request,
sociallogin,
provider=provider,
subject=subject,
)
return
existing_link = (
@@ -50,25 +54,20 @@ class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
)
.first()
)
if existing_link:
if normalize_email(existing_link.user.email) != email:
raise PermissionDenied("Linked social account email mismatch.")
sociallogin.connect(request, existing_link.user)
self._sync_identity(existing_link.user, sociallogin)
return
user = get_user_model().objects.filter(email__iexact=email).first()
if user:
sociallogin.connect(request, user)
self._sync_identity(user, sociallogin)
return
if not getattr(settings, "KEYWARDEN_SOCIAL_ALLOW_AUTO_ONBOARDING", False):
if not existing_link:
self._reject(
request,
"Social auto-onboarding is disabled. Ask an administrator to create/link your account.",
"GitHub is not linked for this account. Sign in with your existing method and link GitHub from Profile.",
)
if sociallogin.is_existing:
if getattr(sociallogin.user, "pk", None) != existing_link.user_id:
raise PermissionDenied("GitHub identity is already linked to another Keywarden account.")
else:
sociallogin.connect(request, existing_link.user)
self._sync_identity(existing_link.user, sociallogin)
def populate_user(self, request, sociallogin, data):
user = super().populate_user(request, sociallogin, data)
email = self._extract_email(sociallogin)
@@ -85,15 +84,7 @@ class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
return user
def save_user(self, request, sociallogin, form=None):
user = super().save_user(request, sociallogin, form=form)
email = self._extract_email(sociallogin)
if normalize_email(user.email) != email:
raise PermissionDenied("Social login email does not match enrolled account email.")
if get_user_role(user, default=None) is None:
set_user_role(user, ROLE_USER)
user.save(update_fields=["is_staff", "is_superuser"])
self._sync_identity(user, sociallogin)
return user
raise PermissionDenied("Social auto-onboarding is disabled. Link GitHub from your profile.")
def _sync_identity(self, user, sociallogin) -> None:
extra_data = dict(sociallogin.account.extra_data or {})
@@ -116,11 +107,67 @@ class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
except IntegrityError as exc:
raise PermissionDenied(str(exc)) from exc
def _handle_connect_flow(self, request, sociallogin, *, provider: str, subject: str) -> None:
if not request.user.is_authenticated:
self._reject(
request,
"Sign in with Keywarden first, then link GitHub from your profile.",
)
user = request.user
existing_subject_link = (
ExternalIdentity.objects.select_related("user")
.filter(
provider_type=ExternalIdentity.ProviderType.SOCIAL,
provider_id=provider,
subject=subject,
)
.first()
)
if existing_subject_link and existing_subject_link.user_id != user.id:
raise PermissionDenied("This GitHub account is already linked to another user.")
existing_provider_link = (
ExternalIdentity.objects.filter(
user=user,
provider_type=ExternalIdentity.ProviderType.SOCIAL,
provider_id=provider,
)
.exclude(subject=subject)
.first()
)
if existing_provider_link:
self._reject(
request,
"A different GitHub account is already linked. Unlink it before linking another account.",
redirect_to="accounts:profile",
)
if sociallogin.is_existing and getattr(sociallogin.user, "pk", None) != user.pk:
raise PermissionDenied("This GitHub account is already linked to another user.")
if not sociallogin.is_existing:
sociallogin.connect(request, user)
self._sync_identity(user, sociallogin)
def _is_connect_flow(self, request, sociallogin) -> bool:
process = str(
(getattr(sociallogin, "state", {}) or {}).get("process")
or request.GET.get("process")
or ""
).strip().lower()
return process == "connect"
def _extract_email(self, sociallogin) -> str:
user_email = normalize_email(getattr(sociallogin.user, "email", ""))
if user_email:
return user_email
return normalize_email((sociallogin.account.extra_data or {}).get("email"))
provider_email = normalize_email((sociallogin.account.extra_data or {}).get("email"))
if provider_email:
return provider_email
for address in getattr(sociallogin, "email_addresses", []):
candidate = normalize_email(getattr(address, "email", ""))
if candidate:
return candidate
return normalize_email(getattr(sociallogin.user, "email", ""))
def _email_verified(self, sociallogin, email: str) -> bool:
normalized = normalize_email(email)
@@ -137,6 +184,9 @@ class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
return True
return False
def _reject(self, request, reason: str) -> None:
def _reject(self, request, reason: str, redirect_to: str = "accounts:login") -> None:
messages.error(request, reason)
raise ImmediateHttpResponse(redirect("accounts:login"))
raise ImmediateHttpResponse(redirect(redirect_to))
def get_connect_redirect_url(self, request, socialaccount):
return reverse("accounts:profile")
@@ -0,0 +1,8 @@
Hello {{ user.get_username }},
Verify your Keywarden email by opening this link:
{{ verification_link }}
This verification link expires in {{ expires_seconds }} seconds.
If you did not request this verification email, you can ignore it.
@@ -0,0 +1 @@
Verify your Keywarden email
@@ -82,6 +82,16 @@
>
Sign in
</button>
{% if password_reset_enabled %}
<div class="text-center">
<a
href="{% url 'accounts:password_reset' %}"
class="text-sm font-medium text-blue-700 hover:underline"
>
Forgot your password?
</a>
</div>
{% endif %}
</form>
{% if oidc_enabled or social_providers %}
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}Password Updated • Keywarden{% endblock %}
{% block content %}
<div class="mx-auto max-w-md rounded-2xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Password updated</h1>
<p class="mt-3 text-sm text-gray-600">
Your Keywarden password has been changed successfully.
</p>
<a
href="{% url 'accounts:login' %}?native=1"
class="mt-6 inline-flex w-full items-center justify-center rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Sign in
</a>
</div>
{% endblock %}
@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}Set New Password • Keywarden{% endblock %}
{% block content %}
<div class="mx-auto max-w-md rounded-2xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Set a new password</h1>
{% if validlink %}
<form method="post" class="mt-6 space-y-4">
{% csrf_token %}
<div>
<label class="mb-2 block text-sm font-medium text-gray-900" for="{{ form.new_password1.id_for_label }}">New password</label>
{{ form.new_password1 }}
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900" for="{{ form.new_password2.id_for_label }}">Confirm password</label>
{{ form.new_password2 }}
</div>
{% if form.errors %}
<div class="rounded-lg bg-red-50 p-3 text-sm text-red-800">
Please fix the errors and try again.
</div>
{% endif %}
<button
type="submit"
class="inline-flex w-full items-center justify-center rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Update password
</button>
</form>
{% else %}
<p class="mt-4 text-sm text-gray-600">
This password reset link is invalid or expired.
</p>
<a
href="{% url 'accounts:password_reset' %}"
class="mt-6 inline-flex w-full items-center justify-center rounded-lg border border-gray-200 bg-gray-50 px-5 py-2.5 text-sm font-semibold text-gray-800 hover:bg-gray-100"
>
Request a new reset link
</a>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}Reset Email Sent • Keywarden{% endblock %}
{% block content %}
<div class="mx-auto max-w-md rounded-2xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Check your email</h1>
<p class="mt-3 text-sm text-gray-600">
If a native Keywarden account exists for the submitted email, a reset link has been sent.
</p>
<p class="mt-3 text-sm text-gray-600">
SSO accounts must reset credentials in the identity provider.
{% if idp_account_portal_url %}
<a href="{{ idp_account_portal_url }}" class="font-semibold text-blue-700 hover:underline">Open IdP portal</a>.
{% endif %}
</p>
<a
href="{% url 'accounts:login' %}?native=1"
class="mt-6 inline-flex w-full items-center justify-center rounded-lg border border-gray-200 bg-gray-50 px-5 py-2.5 text-sm font-semibold text-gray-800 hover:bg-gray-100"
>
Return to login
</a>
</div>
{% endblock %}
@@ -0,0 +1,8 @@
Hello {{ user.get_username }},
You requested a password reset for your Keywarden account.
Reset link:
{{ protocol }}://{{ domain }}{% url 'accounts:password_reset_confirm' uidb64=uid token=token %}
If you did not request this, you can ignore this email.
@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}Reset Password • Keywarden{% endblock %}
{% block content %}
<div class="mx-auto max-w-md rounded-2xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Reset your password</h1>
<p class="mt-2 text-sm text-gray-500">
Enter your Keywarden account email to receive a password reset link.
</p>
<form method="post" class="mt-6 space-y-4">
{% csrf_token %}
<div>
<label class="mb-2 block text-sm font-medium text-gray-900" for="{{ form.email.id_for_label }}">Email</label>
{{ form.email }}
</div>
{% if form.errors %}
<div class="rounded-lg bg-red-50 p-3 text-sm text-red-800">
Please correct the highlighted issues and retry.
</div>
{% endif %}
<button
type="submit"
class="inline-flex w-full items-center justify-center rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Send reset link
</button>
</form>
<p class="mt-6 text-xs text-gray-500">
Accounts managed by SSO do not use local password reset.
{% if idp_account_portal_url %}
Use your identity provider portal:
<a href="{{ idp_account_portal_url }}" class="font-semibold text-blue-700 hover:underline">{{ idp_account_portal_url }}</a>.
{% endif %}
</p>
</div>
{% endblock %}
@@ -0,0 +1 @@
Keywarden password reset
+131 -38
View File
@@ -3,6 +3,16 @@
{% block title %}Profile • Keywarden{% endblock %}
{% block content %}
{% if messages %}
<div class="mb-4 space-y-2">
{% for message in messages %}
<div class="rounded-lg border border-blue-100 bg-blue-50 px-4 py-3 text-sm text-blue-800" role="alert">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
<div class="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(340px,1fr)] lg:items-start">
<div class="space-y-6">
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
@@ -231,6 +241,74 @@
</p>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="space-y-2">
<h2 class="text-base font-semibold text-gray-900">Native account security</h2>
<p class="text-sm text-gray-500">Password reset and email verification for Keywarden-native credentials.</p>
</div>
{% if native_security_enabled %}
<div class="mt-4 space-y-4">
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="flex items-center justify-between gap-3">
<span class="text-sm font-semibold text-gray-900">Email verification</span>
{% if native_account_security and native_account_security.email_verified %}
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-semibold text-emerald-700">Verified</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-700">Unverified</span>
{% endif %}
</div>
{% if email_verification_enabled %}
{% if native_account_security and native_account_security.email_verified %}
<p class="mt-2 text-xs text-gray-600">
Verified at {{ native_account_security.email_verified_at|date:"M j, Y H:i" }}.
</p>
{% else %}
<p class="mt-2 text-xs text-gray-600">
Send a verification link to {{ user.email }}.
</p>
<form method="post" class="mt-3">
{% csrf_token %}
<input type="hidden" name="form_type" value="send_email_verification">
<button
type="submit"
class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
>
Send verification email
</button>
</form>
{% endif %}
{% else %}
<p class="mt-2 text-xs text-gray-600">Email verification is disabled in this environment.</p>
{% endif %}
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<p class="text-sm font-semibold text-gray-900">Password reset</p>
<p class="mt-2 text-xs text-gray-600">
Reset your Keywarden-native password via secure email token flow.
</p>
<a
href="{% url 'accounts:password_reset' %}"
class="mt-3 inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-semibold text-blue-700 hover:bg-blue-100"
>
Start password reset
</a>
</div>
</div>
{% else %}
<div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4">
<p class="text-sm text-gray-700">
Native password reset and local email verification are disabled because this account uses SSO.
</p>
<p class="mt-2 text-xs text-gray-600">
Manage password and email verification in your identity provider.
{% if idp_account_portal_url %}
<a href="{{ idp_account_portal_url }}" class="font-semibold text-blue-700 hover:underline">Open IdP account portal</a>.
{% endif %}
</p>
</div>
{% endif %}
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="space-y-2">
<h2 class="text-base font-semibold text-gray-900">Single Sign-On</h2>
@@ -309,54 +387,69 @@
<article class="overflow-hidden rounded-xl border border-gray-200">
<div class="flex items-center justify-between bg-gray-50 px-4 py-3">
<h3 class="text-sm font-semibold text-gray-900">Social Providers</h3>
<span class="text-xs text-gray-500">GitHub, Google, Apple and more</span>
</div>
<div class="divide-y divide-gray-100">
{% for provider in social_provider_cards %}
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div class="min-w-0">
<p class="text-sm font-semibold text-gray-900">{{ provider.name }}</p>
{% if provider.linked and provider.identity %}
<p class="text-xs text-gray-600">
Linked as {{ provider.identity.email_at_link }}
{% if provider.identity.last_login_at %}• last login {{ provider.identity.last_login_at|date:"M j, Y H:i" }}{% endif %}
</p>
{% elif provider.configured %}
<p class="text-xs text-gray-600">Configured and ready to link.</p>
{% else %}
<p class="text-xs text-gray-500">Placeholder. Configure provider credentials to enable linking.</p>
{% endif %}
</div>
<div class="flex items-center gap-2">
{% if provider.linked %}
<h3 class="text-sm font-semibold text-gray-900">GitHub</h3>
{% if github_identity %}
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold text-emerald-700">Linked</span>
{% elif provider.configured %}
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700">Ready</span>
{% elif github_social_configured %}
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700">Ready to link</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-gray-200 px-2 py-0.5 text-[11px] font-semibold text-gray-700">Placeholder</span>
<span class="inline-flex items-center rounded-full bg-gray-200 px-2 py-0.5 text-[11px] font-semibold text-gray-700">Unavailable</span>
{% endif %}
{% if provider.configured %}
</div>
<div class="space-y-3 p-4">
{% if github_identity %}
<dl class="grid grid-cols-1 gap-2 text-xs text-gray-600">
<div class="grid grid-cols-[96px,1fr] items-start gap-2">
<dt class="font-semibold text-gray-700">Linked email</dt>
<dd>{{ github_identity.email_at_link }}</dd>
</div>
<div class="grid grid-cols-[96px,1fr] items-start gap-2">
<dt class="font-semibold text-gray-700">Subject</dt>
<dd class="break-all font-mono">{{ github_identity.subject }}</dd>
</div>
{% if github_identity.last_login_at %}
<div class="grid grid-cols-[96px,1fr] items-start gap-2">
<dt class="font-semibold text-gray-700">Last login</dt>
<dd>{{ github_identity.last_login_at|date:"M j, Y H:i" }}</dd>
</div>
{% endif %}
</dl>
<div class="flex flex-wrap items-center gap-2">
{% if github_social_connect_url %}
<a
href="{{ provider.login_url }}"
href="{{ github_social_connect_url }}"
class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
>
{% if provider.linked %}Re-link{% else %}Link{% endif %}
Re-link GitHub
</a>
{% endif %}
<form method="post" class="inline-flex">
{% csrf_token %}
<input type="hidden" name="form_type" value="unlink_github">
<button
type="submit"
class="inline-flex items-center rounded-lg border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 focus:outline-none focus:ring-2 focus:ring-rose-300"
>
Unlink GitHub
</button>
</form>
</div>
{% elif github_social_configured %}
<p class="text-sm text-gray-600">
Link GitHub while signed in. GitHub login is only available after linking to an existing Keywarden account.
</p>
<a
href="{{ github_social_connect_url }}"
class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
>
Link GitHub
</a>
{% else %}
<button
type="button"
disabled
class="inline-flex cursor-not-allowed items-center rounded-lg border border-gray-200 bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-500"
>
Configure
</button>
<p class="text-sm text-gray-600">
GitHub authentication is not configured in this environment.
</p>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</article>
</div>
</section>
+231 -2
View File
@@ -1,11 +1,21 @@
from __future__ import annotations
from types import SimpleNamespace
from allauth.exceptions import ImmediateHttpResponse
from django.contrib.auth.models import AnonymousUser
from django.contrib.messages.storage.fallback import FallbackStorage
from django.contrib.sessions.middleware import SessionMiddleware
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.test import TestCase
from django.core import signing
from django.test import RequestFactory, TestCase
from django.urls import reverse
from apps.accounts.forms import NativePasswordResetForm
from apps.accounts.identity import build_unique_username, normalize_email
from apps.accounts.models import ExternalIdentity
from apps.accounts.models import ExternalIdentity, NativeAccountSecurity
from apps.accounts.social_auth import KeywardenSocialAccountAdapter
class IdentityHelpersTests(TestCase):
@@ -42,3 +52,222 @@ class ExternalIdentityTests(TestCase):
email_at_link="charlie@example.com",
)
self.assertIn("corporate-sso", str(identity))
class _DummySocialLogin:
def __init__(
self,
*,
email: str,
provider: str = "github",
uid: str = "subject-1",
state: dict | None = None,
is_existing: bool = False,
existing_user=None,
email_verified: bool = True,
):
self.account = SimpleNamespace(
provider=provider,
uid=uid,
extra_data={"email": email, "name": "Test User"},
)
self.user = existing_user or SimpleNamespace(email=email, pk=None)
self.state = state or {}
self.is_existing = is_existing
self.email_addresses = [SimpleNamespace(email=email, verified=email_verified)]
self.connected_user = None
def connect(self, request, user) -> None:
self.connected_user = user
self.user = user
self.is_existing = True
class SocialAdapterTests(TestCase):
def setUp(self):
super().setUp()
self.factory = RequestFactory()
self.adapter = KeywardenSocialAccountAdapter()
def _request(self, *, user=None, path: str = "/accounts/sso/github/login/callback/"):
request = self.factory.get(path)
session_middleware = SessionMiddleware(lambda r: None)
session_middleware.process_request(request)
request.session.save()
request._messages = FallbackStorage(request)
request.user = user or AnonymousUser()
return request
def test_login_requires_prelinked_github_identity(self):
User = get_user_model()
User.objects.create_user(username="social1", email="social1@example.com", password="pass12345")
request = self._request()
sociallogin = _DummySocialLogin(
email="social1@example.com",
state={"process": "login"},
)
with self.assertRaises(ImmediateHttpResponse):
self.adapter.pre_social_login(request, sociallogin)
self.assertFalse(
ExternalIdentity.objects.filter(
provider_type=ExternalIdentity.ProviderType.SOCIAL,
provider_id="github",
).exists()
)
def test_connect_flow_links_github_to_current_user(self):
User = get_user_model()
user = User.objects.create_user(
username="social2",
email="social2@example.com",
password="pass12345",
)
request = self._request(user=user)
sociallogin = _DummySocialLogin(
email="gh-social2@example.net",
uid="github-sub-2",
state={"process": "connect"},
)
self.adapter.pre_social_login(request, sociallogin)
identity = ExternalIdentity.objects.get(
provider_type=ExternalIdentity.ProviderType.SOCIAL,
provider_id="github",
subject="github-sub-2",
)
self.assertEqual(identity.user_id, user.id)
self.assertEqual(identity.email_at_link, "gh-social2@example.net")
self.assertEqual(sociallogin.connected_user, user)
def test_login_succeeds_when_github_identity_is_linked(self):
User = get_user_model()
user = User.objects.create_user(
username="social3",
email="social3@example.com",
password="pass12345",
)
ExternalIdentity.objects.create(
user=user,
provider_type=ExternalIdentity.ProviderType.SOCIAL,
provider_id="github",
subject="github-sub-3",
email_at_link="gh-social3@example.net",
)
request = self._request()
sociallogin = _DummySocialLogin(
email="gh-social3@example.net",
uid="github-sub-3",
state={"process": "login"},
)
self.adapter.pre_social_login(request, sociallogin)
self.assertEqual(sociallogin.connected_user, user)
identity = ExternalIdentity.objects.get(
provider_type=ExternalIdentity.ProviderType.SOCIAL,
provider_id="github",
subject="github-sub-3",
)
self.assertIsNotNone(identity.last_login_at)
def test_connect_flow_allows_local_and_github_email_mismatch(self):
User = get_user_model()
user = User.objects.create_user(
username="social4",
email="social4@example.com",
password="pass12345",
)
request = self._request(user=user)
sociallogin = _DummySocialLogin(
email="different@example.com",
uid="github-sub-4",
state={"process": "connect"},
)
self.adapter.pre_social_login(request, sociallogin)
identity = ExternalIdentity.objects.get(
provider_type=ExternalIdentity.ProviderType.SOCIAL,
provider_id="github",
subject="github-sub-4",
)
self.assertEqual(identity.user_id, user.id)
self.assertEqual(identity.email_at_link, "different@example.com")
class NativePasswordResetFormTests(TestCase):
def test_native_password_reset_excludes_sso_accounts(self):
User = get_user_model()
native_user = User.objects.create_user(
username="native-user",
email="native@example.com",
password="pass12345",
)
sso_user = User.objects.create_user(
username="sso-user",
email="sso@example.com",
password="pass12345",
)
ExternalIdentity.objects.create(
user=sso_user,
provider_type=ExternalIdentity.ProviderType.OIDC,
provider_id="oidc",
subject="oidc-subject",
email_at_link="sso@example.com",
)
native_form = NativePasswordResetForm(data={"email": "native@example.com"})
self.assertTrue(native_form.is_valid())
native_users = list(native_form.get_users("native@example.com"))
self.assertEqual([user.pk for user in native_users], [native_user.pk])
sso_form = NativePasswordResetForm(data={"email": "sso@example.com"})
self.assertTrue(sso_form.is_valid())
sso_users = list(sso_form.get_users("sso@example.com"))
self.assertEqual(sso_users, [])
class NativeEmailVerificationTests(TestCase):
def test_email_verification_marks_native_account_verified(self):
User = get_user_model()
user = User.objects.create_user(
username="verify-native",
email="verify-native@example.com",
password="pass12345",
)
token = signing.dumps(
{"uid": user.pk, "email": "verify-native@example.com"},
salt="accounts.native-email-verification",
)
response = self.client.get(reverse("accounts:email_verify", kwargs={"token": token}))
self.assertEqual(response.status_code, 302)
security = NativeAccountSecurity.objects.get(user=user)
self.assertTrue(security.email_verified)
self.assertIsNotNone(security.email_verified_at)
def test_email_verification_does_not_verify_sso_account(self):
User = get_user_model()
user = User.objects.create_user(
username="verify-sso",
email="verify-sso@example.com",
password="pass12345",
)
ExternalIdentity.objects.create(
user=user,
provider_type=ExternalIdentity.ProviderType.OIDC,
provider_id="oidc",
subject="sso-subject-verify",
email_at_link="verify-sso@example.com",
)
token = signing.dumps(
{"uid": user.pk, "email": "verify-sso@example.com"},
salt="accounts.native-email-verification",
)
response = self.client.get(reverse("accounts:email_verify", kwargs={"token": token}))
self.assertEqual(response.status_code, 302)
self.assertFalse(NativeAccountSecurity.objects.filter(user=user).exists())
+9 -1
View File
@@ -6,6 +6,14 @@ app_name = "accounts"
urlpatterns = [
path("login/", views.login_view, name="login"),
path("logout/", views.logout_view, name="logout"),
path("password-reset/", views.password_reset_view, name="password_reset"),
path("password-reset/done/", views.password_reset_done_view, name="password_reset_done"),
path(
"password-reset/confirm/<uidb64>/<token>/",
views.password_reset_confirm_view,
name="password_reset_confirm",
),
path("password-reset/complete/", views.password_reset_complete_view, name="password_reset_complete"),
path("email/verify/<str:token>/", views.email_verify_view, name="email_verify"),
path("profile/", views.profile, name="profile"),
]
+260 -48
View File
@@ -1,17 +1,76 @@
from datetime import timedelta
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import logout
from django.contrib.auth import views as auth_views
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
from django.core import signing
from django.core.mail import send_mail
from django.db import IntegrityError
from django.shortcuts import redirect, render
from django.template.loader import render_to_string
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from urllib.parse import urlparse
from apps.keys.certificates import issue_certificate_for_key
from apps.keys.models import SSHKey
from .forms import ErasureRequestForm, SSHKeyForm
from .models import ErasureRequest, ExternalIdentity
from .forms import ErasureRequestForm, NativePasswordResetForm, SSHKeyForm, StyledSetPasswordForm
from .identity import normalize_email
from .models import ErasureRequest, ExternalIdentity, NativeAccountSecurity
_EMAIL_VERIFICATION_SIGNER_SALT = "accounts.native-email-verification"
def _unlink_social_account(user, provider_id: str) -> None:
try:
from allauth.socialaccount.models import SocialAccount
except Exception:
return
SocialAccount.objects.filter(user=user, provider=provider_id).delete()
def _has_sso_identity(user) -> bool:
return ExternalIdentity.objects.filter(user=user).exists()
def _idp_account_portal_url() -> str:
explicit = (getattr(settings, "KEYWARDEN_IDP_ACCOUNT_PORTAL_URL", "") or "").strip()
if explicit:
return explicit
issuer = (getattr(settings, "KEYWARDEN_OIDC_ISSUER", "") or "").strip()
if issuer:
return issuer
endpoint = (
getattr(settings, "OIDC_OP_AUTHORIZATION_ENDPOINT", "")
or getattr(settings, "OIDC_OP_DISCOVERY_ENDPOINT", "")
or ""
)
if not endpoint:
return ""
parsed = urlparse(endpoint)
if not parsed.scheme or not parsed.netloc:
return ""
return f"{parsed.scheme}://{parsed.netloc}/"
def _issue_email_verification_token(user) -> str:
payload = {"uid": user.pk, "email": normalize_email(user.email)}
return signing.dumps(payload, salt=_EMAIL_VERIFICATION_SIGNER_SALT)
def _resolve_email_verification_token(token: str):
max_age = int(getattr(settings, "KEYWARDEN_EMAIL_VERIFICATION_TOKEN_MAX_AGE_SECONDS", 86400))
payload = signing.loads(token, salt=_EMAIL_VERIFICATION_SIGNER_SALT, max_age=max_age)
user_id = payload.get("uid")
email = normalize_email(payload.get("email"))
if not user_id or not email:
raise signing.BadSignature("Invalid token payload.")
return user_id, email
def _oidc_provider_label() -> str:
@@ -62,7 +121,85 @@ def profile(request):
key_form.add_error("public_key", "Key already exists.")
except Exception:
key_form.add_error(None, "Certificate issuance failed.")
elif form_type == "unlink_github":
key_form = SSHKeyForm()
erasure_form = ErasureRequestForm()
deleted_count, _ = ExternalIdentity.objects.filter(
user=request.user,
provider_type=ExternalIdentity.ProviderType.SOCIAL,
provider_id__iexact="github",
).delete()
_unlink_social_account(request.user, "github")
if deleted_count:
messages.success(request, "GitHub login was unlinked from your account.")
else:
messages.info(request, "No GitHub link was found on your account.")
return redirect("accounts:profile")
elif form_type == "send_email_verification":
key_form = SSHKeyForm()
erasure_form = ErasureRequestForm()
idp_account_portal_url = _idp_account_portal_url()
if _has_sso_identity(request.user):
if idp_account_portal_url:
messages.info(
request,
f"This account uses SSO. Verify your email in your identity provider: {idp_account_portal_url}",
)
else:
messages.info(
request,
"This account uses SSO. Verify your email in your configured identity provider.",
)
return redirect("accounts:profile")
if not bool(getattr(settings, "KEYWARDEN_EMAIL_VERIFICATION_ENABLED", True)):
messages.info(request, "Email verification is disabled in this environment.")
return redirect("accounts:profile")
security, _ = NativeAccountSecurity.objects.get_or_create(user=request.user)
if security.email_verified:
messages.success(request, "Your email is already verified.")
return redirect("accounts:profile")
cooldown_seconds = int(getattr(settings, "KEYWARDEN_EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS", 300))
now = timezone.now()
if (
security.verification_email_sent_at
and (now - security.verification_email_sent_at) < timedelta(seconds=cooldown_seconds)
):
messages.info(
request,
f"Verification email recently sent. Please wait up to {cooldown_seconds} seconds before retrying.",
)
return redirect("accounts:profile")
token = _issue_email_verification_token(request.user)
verification_link = request.build_absolute_uri(
reverse("accounts:email_verify", kwargs={"token": token})
)
context = {
"user": request.user,
"verification_link": verification_link,
"expires_seconds": int(getattr(settings, "KEYWARDEN_EMAIL_VERIFICATION_TOKEN_MAX_AGE_SECONDS", 86400)),
}
subject = render_to_string("accounts/email_verification_subject.txt", context).strip()
body = render_to_string("accounts/email_verification_email.txt", context)
try:
send_mail(
subject=subject,
message=body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", "no-reply@localhost"),
recipient_list=[request.user.email],
fail_silently=False,
)
except Exception:
messages.error(request, "Failed to send verification email. Contact an administrator.")
return redirect("accounts:profile")
security.verification_email_sent_at = now
security.save(update_fields=["verification_email_sent_at", "updated_at"])
messages.success(request, f"Verification email sent to {request.user.email}.")
return redirect("accounts:profile")
elif form_type == "erasure":
key_form = SSHKeyForm()
erasure_form = ErasureRequestForm(request.POST)
if erasure_form.is_valid():
@@ -74,61 +211,38 @@ def profile(request):
reason=erasure_form.cleaned_data["reason"].strip(),
)
return redirect("accounts:profile")
else:
key_form = SSHKeyForm()
erasure_form = ErasureRequestForm()
else:
erasure_form = ErasureRequestForm()
key_form = SSHKeyForm()
ssh_keys = SSHKey.objects.filter(user=request.user).order_by("-created_at")
external_identities = ExternalIdentity.objects.filter(user=request.user).order_by("-last_login_at", "provider_id")
has_sso_identity = external_identities.exists()
oidc_identity = external_identities.filter(provider_type=ExternalIdentity.ProviderType.OIDC).first()
social_identity_map = {
identity.provider_id.lower(): identity
for identity in external_identities.filter(provider_type=ExternalIdentity.ProviderType.SOCIAL)
}
configured_social_map = {
provider.get("id", "").strip().lower(): provider
github_identity = external_identities.filter(
provider_type=ExternalIdentity.ProviderType.SOCIAL,
provider_id__iexact="github",
).first()
github_provider = next(
(
provider
for provider in getattr(settings, "KEYWARDEN_SOCIAL_LOGIN_PROVIDERS", [])
if provider.get("id")
}
social_provider_defaults = [
("github", "GitHub"),
("google", "Google"),
("apple", "Apple"),
("gitlab", "GitLab"),
("microsoft", "Microsoft Entra ID"),
]
social_provider_cards = []
seen_provider_ids = set()
for provider_id, provider_name in social_provider_defaults:
provider_config = configured_social_map.get(provider_id, {})
identity = social_identity_map.get(provider_id)
social_provider_cards.append(
{
"id": provider_id,
"name": provider_name,
"configured": bool(provider_config.get("login_url")),
"login_url": provider_config.get("login_url", ""),
"linked": bool(identity),
"identity": identity,
}
if (provider.get("id", "").strip().lower() == "github")
),
{},
)
seen_provider_ids.add(provider_id)
github_login_url = github_provider.get("login_url", "")
github_connect_url = github_provider.get("connect_url", "")
if not github_connect_url and github_login_url:
github_connect_url = f"{github_login_url}?process=connect"
for provider_id, provider_config in configured_social_map.items():
if provider_id in seen_provider_ids:
continue
identity = social_identity_map.get(provider_id)
social_provider_cards.append(
{
"id": provider_id,
"name": provider_config.get("name", provider_id.title()),
"configured": bool(provider_config.get("login_url")),
"login_url": provider_config.get("login_url", ""),
"linked": bool(identity),
"identity": identity,
}
)
native_security = None
if not has_sso_identity:
native_security, _ = NativeAccountSecurity.objects.get_or_create(user=request.user)
idp_account_portal_url = _idp_account_portal_url()
context = {
"user": request.user,
@@ -137,7 +251,13 @@ def profile(request):
"oidc_identity": oidc_identity,
"oidc_provider_id": getattr(settings, "KEYWARDEN_OIDC_PROVIDER_ID", "oidc"),
"oidc_issuer": getattr(settings, "KEYWARDEN_OIDC_ISSUER", ""),
"social_provider_cards": social_provider_cards,
"github_social_configured": bool(github_login_url),
"github_social_connect_url": github_connect_url,
"github_identity": github_identity,
"native_account_security": native_security,
"native_security_enabled": not has_sso_identity,
"email_verification_enabled": bool(getattr(settings, "KEYWARDEN_EMAIL_VERIFICATION_ENABLED", True)),
"idp_account_portal_url": idp_account_portal_url,
"erasure_request": erasure_request,
"erasure_form": erasure_form,
"key_form": key_form,
@@ -180,6 +300,8 @@ def login_view(request):
"show_native_login": show_native_login,
"can_use_native_login": auth_mode in {"native", "hybrid"},
"show_sso_choice": has_sso_options and not show_native_login,
"password_reset_enabled": auth_mode in {"native", "hybrid"},
"idp_account_portal_url": _idp_account_portal_url(),
},
)(request)
@@ -187,3 +309,93 @@ def login_view(request):
def logout_view(request):
logout(request)
return redirect(getattr(settings, "LOGOUT_REDIRECT_URL", "/"))
def password_reset_view(request):
return auth_views.PasswordResetView.as_view(
template_name="accounts/password_reset_form.html",
email_template_name="accounts/password_reset_email.txt",
subject_template_name="accounts/password_reset_subject.txt",
success_url=reverse_lazy("accounts:password_reset_done"),
form_class=NativePasswordResetForm,
extra_context={
"idp_account_portal_url": _idp_account_portal_url(),
},
)(request)
def password_reset_done_view(request):
return auth_views.PasswordResetDoneView.as_view(
template_name="accounts/password_reset_done.html",
extra_context={
"idp_account_portal_url": _idp_account_portal_url(),
},
)(request)
def password_reset_confirm_view(request, uidb64, token):
return auth_views.PasswordResetConfirmView.as_view(
template_name="accounts/password_reset_confirm.html",
success_url=reverse_lazy("accounts:password_reset_complete"),
form_class=StyledSetPasswordForm,
)(request, uidb64=uidb64, token=token)
def password_reset_complete_view(request):
return auth_views.PasswordResetCompleteView.as_view(
template_name="accounts/password_reset_complete.html",
)(request)
def email_verify_view(request, token):
if not bool(getattr(settings, "KEYWARDEN_EMAIL_VERIFICATION_ENABLED", True)):
messages.info(request, "Email verification is disabled in this environment.")
return redirect("accounts:login")
try:
user_id, token_email = _resolve_email_verification_token(token)
except signing.SignatureExpired:
messages.error(request, "This verification link has expired. Request a new email from your profile.")
return redirect("accounts:login")
except signing.BadSignature:
messages.error(request, "Invalid verification link.")
return redirect("accounts:login")
user = (
request.user
if request.user.is_authenticated and request.user.pk == user_id
else None
)
if user is None:
from django.contrib.auth import get_user_model
user = get_user_model().objects.filter(pk=user_id, is_active=True).first()
if not user:
messages.error(request, "Verification target account was not found.")
return redirect("accounts:login")
if _has_sso_identity(user):
idp_account_portal_url = _idp_account_portal_url()
if idp_account_portal_url:
messages.info(
request,
f"This account is managed by SSO. Verify email at your identity provider: {idp_account_portal_url}",
)
else:
messages.info(
request,
"This account is managed by SSO. Verify email at your identity provider.",
)
return redirect("accounts:profile" if request.user.is_authenticated else "accounts:login")
if normalize_email(user.email) != token_email:
messages.error(request, "Verification link does not match the current account email.")
return redirect("accounts:login")
security, _ = NativeAccountSecurity.objects.get_or_create(user=user)
if not security.email_verified:
security.email_verified = True
security.email_verified_at = timezone.now()
security.save(update_fields=["email_verified", "email_verified_at", "updated_at"])
messages.success(request, "Your email address has been verified.")
return redirect("accounts:profile" if request.user.is_authenticated else "accounts:login")
+31 -70
View File
@@ -39,52 +39,43 @@ CSRF_TRUSTED_ORIGINS = [
for origin in os.getenv("KEYWARDEN_TRUSTED_ORIGINS", "").split(",")
if origin.strip()
]
KEYWARDEN_IDP_ACCOUNT_PORTAL_URL = os.getenv("KEYWARDEN_IDP_ACCOUNT_PORTAL_URL", "").strip()
EMAIL_BACKEND = os.getenv(
"KEYWARDEN_EMAIL_BACKEND",
"django.core.mail.backends.smtp.EmailBackend",
)
EMAIL_HOST = os.getenv("KEYWARDEN_EMAIL_HOST", "localhost")
EMAIL_PORT = int(os.getenv("KEYWARDEN_EMAIL_PORT", "25"))
EMAIL_HOST_USER = os.getenv("KEYWARDEN_EMAIL_HOST_USER", "")
EMAIL_HOST_PASSWORD = os.getenv("KEYWARDEN_EMAIL_HOST_PASSWORD", "")
EMAIL_USE_TLS = env_bool("KEYWARDEN_EMAIL_USE_TLS", default=False)
EMAIL_USE_SSL = env_bool("KEYWARDEN_EMAIL_USE_SSL", default=False)
DEFAULT_FROM_EMAIL = os.getenv("KEYWARDEN_DEFAULT_FROM_EMAIL", "no-reply@localhost")
SERVER_EMAIL = os.getenv("KEYWARDEN_SERVER_EMAIL", DEFAULT_FROM_EMAIL)
KEYWARDEN_EMAIL_VERIFICATION_ENABLED = env_bool("KEYWARDEN_EMAIL_VERIFICATION_ENABLED", default=True)
KEYWARDEN_EMAIL_VERIFICATION_TOKEN_MAX_AGE_SECONDS = int(
os.getenv("KEYWARDEN_EMAIL_VERIFICATION_TOKEN_MAX_AGE_SECONDS", "86400")
)
KEYWARDEN_EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS = int(
os.getenv("KEYWARDEN_EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS", "300")
)
KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID = os.getenv("KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID", "").strip()
KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET = os.getenv("KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET", "").strip()
KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID = os.getenv("KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID", "").strip()
KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET = os.getenv("KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET", "").strip()
KEYWARDEN_SOCIAL_APPLE_CLIENT_ID = os.getenv("KEYWARDEN_SOCIAL_APPLE_CLIENT_ID", "").strip()
KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET = os.getenv("KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET", "").strip()
KEYWARDEN_SOCIAL_GOOGLE_ENABLED = env_bool(
"KEYWARDEN_SOCIAL_GOOGLE_ENABLED",
default=bool(KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID and KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET),
)
KEYWARDEN_SOCIAL_GITHUB_ENABLED = env_bool(
"KEYWARDEN_SOCIAL_GITHUB_ENABLED",
default=bool(KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID and KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET),
)
KEYWARDEN_SOCIAL_APPLE_ENABLED = env_bool(
"KEYWARDEN_SOCIAL_APPLE_ENABLED",
default=bool(KEYWARDEN_SOCIAL_APPLE_CLIENT_ID and KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET),
)
KEYWARDEN_SOCIAL_GOOGLE_CONFIGURED = bool(
KEYWARDEN_SOCIAL_GOOGLE_ENABLED
and KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID
and KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET
)
KEYWARDEN_SOCIAL_GITHUB_CONFIGURED = bool(
KEYWARDEN_SOCIAL_GITHUB_ENABLED
and KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID
and KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET
)
KEYWARDEN_SOCIAL_APPLE_CONFIGURED = bool(
KEYWARDEN_SOCIAL_APPLE_ENABLED
and KEYWARDEN_SOCIAL_APPLE_CLIENT_ID
and KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET
)
KEYWARDEN_SOCIAL_AUTH_ENABLED = (
KEYWARDEN_AUTH_MODE_REQUESTED == "hybrid"
and any(
(
KEYWARDEN_SOCIAL_GOOGLE_CONFIGURED,
KEYWARDEN_SOCIAL_GITHUB_CONFIGURED,
KEYWARDEN_SOCIAL_APPLE_CONFIGURED,
)
)
and KEYWARDEN_SOCIAL_GITHUB_CONFIGURED
)
# Default to secure cookies and respect TLS termination headers.
@@ -129,12 +120,8 @@ if KEYWARDEN_SOCIAL_AUTH_ENABLED:
"allauth.socialaccount",
]
)
if KEYWARDEN_SOCIAL_GOOGLE_CONFIGURED:
INSTALLED_APPS.append("allauth.socialaccount.providers.google")
if KEYWARDEN_SOCIAL_GITHUB_CONFIGURED:
INSTALLED_APPS.append("allauth.socialaccount.providers.github")
if KEYWARDEN_SOCIAL_APPLE_CONFIGURED:
INSTALLED_APPS.append("allauth.socialaccount.providers.apple")
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
@@ -392,17 +379,14 @@ KEYWARDEN_OIDC_ENABLED = bool(
KEYWARDEN_OIDC_LOGIN_ENABLED = KEYWARDEN_OIDC_ENABLED and KEYWARDEN_AUTH_MODE_REQUESTED in {"oidc", "hybrid"}
KEYWARDEN_SOCIAL_LOGIN_PROVIDERS = []
if KEYWARDEN_SOCIAL_AUTH_ENABLED and KEYWARDEN_SOCIAL_GOOGLE_CONFIGURED:
KEYWARDEN_SOCIAL_LOGIN_PROVIDERS.append(
{"id": "google", "name": "Google", "login_url": "/accounts/sso/google/login/"}
)
if KEYWARDEN_SOCIAL_AUTH_ENABLED and KEYWARDEN_SOCIAL_GITHUB_CONFIGURED:
KEYWARDEN_SOCIAL_LOGIN_PROVIDERS.append(
{"id": "github", "name": "GitHub", "login_url": "/accounts/sso/github/login/"}
)
if KEYWARDEN_SOCIAL_AUTH_ENABLED and KEYWARDEN_SOCIAL_APPLE_CONFIGURED:
KEYWARDEN_SOCIAL_LOGIN_PROVIDERS.append(
{"id": "apple", "name": "Apple", "login_url": "/accounts/sso/apple/login/"}
{
"id": "github",
"name": "GitHub",
"login_url": "/accounts/sso/github/login/",
"connect_url": "/accounts/sso/github/login/?process=connect",
}
)
if KEYWARDEN_SOCIAL_AUTH_ENABLED:
@@ -411,25 +395,14 @@ if KEYWARDEN_SOCIAL_AUTH_ENABLED:
ACCOUNT_UNIQUE_EMAIL = True
ACCOUNT_USERNAME_REQUIRED = False
ACCOUNT_AUTHENTICATION_METHOD = "email"
KEYWARDEN_SOCIAL_ALLOW_AUTO_ONBOARDING = env_bool("KEYWARDEN_SOCIAL_ALLOW_AUTO_ONBOARDING", default=False)
SOCIALACCOUNT_AUTO_SIGNUP = KEYWARDEN_SOCIAL_ALLOW_AUTO_ONBOARDING
# Social identities can only be linked to an existing Keywarden account.
KEYWARDEN_SOCIAL_ALLOW_AUTO_ONBOARDING = False
SOCIALACCOUNT_AUTO_SIGNUP = False
SOCIALACCOUNT_EMAIL_AUTHENTICATION = False
SOCIALACCOUNT_ADAPTER = "apps.accounts.social_auth.KeywardenSocialAccountAdapter"
SOCIALACCOUNT_LOGIN_ON_GET = True
SOCIALACCOUNT_PROVIDERS = {}
if KEYWARDEN_SOCIAL_GOOGLE_CONFIGURED:
SOCIALACCOUNT_PROVIDERS["google"] = {
"SCOPE": ["profile", "email"],
"APPS": [
{
"client_id": KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID,
"secret": KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET,
"key": "",
}
],
}
if KEYWARDEN_SOCIAL_GITHUB_CONFIGURED:
SOCIALACCOUNT_PROVIDERS["github"] = {
"SCOPE": ["read:user", "user:email"],
@@ -442,18 +415,6 @@ if KEYWARDEN_SOCIAL_AUTH_ENABLED:
],
}
if KEYWARDEN_SOCIAL_APPLE_CONFIGURED:
SOCIALACCOUNT_PROVIDERS["apple"] = {
"SCOPE": ["name", "email"],
"APPS": [
{
"client_id": KEYWARDEN_SOCIAL_APPLE_CLIENT_ID,
"secret": KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET,
"key": "",
}
],
}
KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL = env_bool(
"KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL",
default=True,
+20
View File
@@ -0,0 +1,20 @@
(function () {
const allauth = window.allauth = window.allauth || {}
function manageEmailForm (o) {
const actions = document.getElementsByName('action_remove')
if (actions.length) {
actions[0].addEventListener('click', function (e) {
if (!window.confirm(o.i18n.confirmDelete)) {
e.preventDefault()
}
})
}
}
allauth.account = {
forms: {
manageEmailForm
}
}
})()
+12
View File
@@ -0,0 +1,12 @@
(function () {
document.addEventListener('DOMContentLoaded', function () {
Array.from(document.querySelectorAll('script[data-allauth-onload]')).forEach(scriptElt => {
const funcRef = scriptElt.dataset.allauthOnload
if (typeof funcRef === 'string' && funcRef.startsWith('allauth.')) {
const funcArg = JSON.parse(scriptElt.textContent)
const func = funcRef.split('.').reduce((acc, part) => acc && acc[part], window)
func(funcArg)
}
})
})
})()