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
+1087 -228

No files matched your search

+17 -7
View File
@@ -4,6 +4,7 @@ KEYWARDEN_DEBUG=True
KEYWARDEN_ALLOWED_HOSTS=* KEYWARDEN_ALLOWED_HOSTS=*
KEYWARDEN_TRUSTED_ORIGINS=https://reverse.proxy.domain.xyz,https://127.0.0.1 KEYWARDEN_TRUSTED_ORIGINS=https://reverse.proxy.domain.xyz,https://127.0.0.1
KEYWARDEN_DOMAIN=https://example.domain.xyz KEYWARDEN_DOMAIN=https://example.domain.xyz
KEYWARDEN_IDP_ACCOUNT_PORTAL_URL=
KEYWARDEN_SHELL_REMOTE_COMMAND=/bin/bash KEYWARDEN_SHELL_REMOTE_COMMAND=/bin/bash
# Set to 1 to include SSH probe/certificate diagnostics in shell failures. # Set to 1 to include SSH probe/certificate diagnostics in shell failures.
KEYWARDEN_SHELL_DEBUG=0 KEYWARDEN_SHELL_DEBUG=0
@@ -33,6 +34,22 @@ KEYWARDEN_ADMIN_PASSWORD=password
KEYWARDEN_AUTH_MODE=native 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. # OIDC (optional) Currently supports Authentik officially and tested. Other solutions such as Keycloak may work.
# KEYWARDEN_OIDC_CLIENT_ID= # KEYWARDEN_OIDC_CLIENT_ID=
@@ -58,16 +75,9 @@ KEYWARDEN_AUTH_MODE=native
# Social login (optional) # Social login (optional)
# KEYWARDEN_SITE_ID=1 # KEYWARDEN_SITE_ID=1
# KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL=true # 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_ENABLED=false
# KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID= # KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID=
# KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET= # KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET=
# KEYWARDEN_SOCIAL_APPLE_ENABLED=false
# KEYWARDEN_SOCIAL_APPLE_CLIENT_ID=
# KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET=
# Authentik Secrets # Authentik Secrets
AUTHENTIK_PG_PASS= AUTHENTIK_PG_PASS=
+12 -15
View File
@@ -2,7 +2,7 @@
This guide configures: This guide configures:
- OIDC client login (Authentik, Authelia, Keycloak, or other OIDC IdP) - OIDC client login (Authentik, Authelia, Keycloak, or other OIDC IdP)
- Social login (Google, GitHub, Apple) - Social login (GitHub)
Security model implemented by Keywarden: Security model implemented by Keywarden:
- External identities are mapped by immutable provider subject (`sub`/`uid`) and provider ID. - 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 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 ```env
KEYWARDEN_SITE_ID=1 KEYWARDEN_SITE_ID=1
KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL=true 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_ENABLED=true
KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID=<github-client-id> KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID=<github-client-id>
KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET=<github-secret> 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: Keywarden includes social auth URLs under:
@@ -147,10 +139,15 @@ Keywarden includes social auth URLs under:
/accounts/sso/ /accounts/sso/
``` ```
Provider callback URLs to register: Provider callback URL to register:
- Google: `https://<your-keywarden-domain>/accounts/sso/google/login/callback/`
- GitHub: `https://<your-keywarden-domain>/accounts/sso/github/login/callback/` - 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 ## 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. - Login page shows social buttons for enabled providers.
- `/oidc/authenticate/` starts IdP login. - `/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. - Repeat logins map to same local user via external identity subject.
- Changing provider email for the same subject causes login rejection. - 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 django.utils import timezone
from unfold.admin import ModelAdmin from unfold.admin import ModelAdmin
from .models import ErasureRequest, ExternalIdentity from .models import ErasureRequest, ExternalIdentity, NativeAccountSecurity
class ErasureRequestAdminForm(forms.ModelForm): class ErasureRequestAdminForm(forms.ModelForm):
@@ -89,3 +89,20 @@ class ExternalIdentityAdmin(ModelAdmin):
def has_delete_permission(self, request, obj=None) -> bool: def has_delete_permission(self, request, obj=None) -> bool:
return False 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 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): 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 ErasureRequest(models.Model):
class Status(models.TextChoices): class Status(models.TextChoices):
PENDING = "pending", "Pending" PENDING = "pending", "Pending"
+88 -38
View File
@@ -2,16 +2,14 @@ from __future__ import annotations
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import get_user_model
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import IntegrityError from django.db import IntegrityError
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse
from allauth.exceptions import ImmediateHttpResponse from allauth.exceptions import ImmediateHttpResponse
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from apps.core.rbac import ROLE_USER, get_user_role, set_user_role
from .identity import ( from .identity import (
build_unique_username, build_unique_username,
normalize_email, normalize_email,
@@ -23,8 +21,11 @@ from .models import ExternalIdentity
class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter): class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
def pre_social_login(self, request, sociallogin): 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) email = self._extract_email(sociallogin)
provider = sociallogin.account.provider
subject = str(sociallogin.account.uid or "").strip() subject = str(sociallogin.account.uid or "").strip()
if not subject: if not subject:
self._reject(request, "Login failed: provider did not return a stable user id.") 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): if not self._email_verified(sociallogin, email):
self._reject(request, "Login failed: provider email is not verified.") self._reject(request, "Login failed: provider email is not verified.")
if sociallogin.is_existing: if self._is_connect_flow(request, sociallogin):
if normalize_email(sociallogin.user.email) != email: self._handle_connect_flow(
raise PermissionDenied("Social login email does not match enrolled account email.") request,
self._sync_identity(sociallogin.user, sociallogin) sociallogin,
provider=provider,
subject=subject,
)
return return
existing_link = ( existing_link = (
@@ -50,25 +54,20 @@ class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
) )
.first() .first()
) )
if existing_link: if not 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):
self._reject( self._reject(
request, 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): def populate_user(self, request, sociallogin, data):
user = super().populate_user(request, sociallogin, data) user = super().populate_user(request, sociallogin, data)
email = self._extract_email(sociallogin) email = self._extract_email(sociallogin)
@@ -85,15 +84,7 @@ class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
return user return user
def save_user(self, request, sociallogin, form=None): def save_user(self, request, sociallogin, form=None):
user = super().save_user(request, sociallogin, form=form) raise PermissionDenied("Social auto-onboarding is disabled. Link GitHub from your profile.")
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
def _sync_identity(self, user, sociallogin) -> None: def _sync_identity(self, user, sociallogin) -> None:
extra_data = dict(sociallogin.account.extra_data or {}) extra_data = dict(sociallogin.account.extra_data or {})
@@ -116,11 +107,67 @@ class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
except IntegrityError as exc: except IntegrityError as exc:
raise PermissionDenied(str(exc)) from 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: def _extract_email(self, sociallogin) -> str:
user_email = normalize_email(getattr(sociallogin.user, "email", "")) provider_email = normalize_email((sociallogin.account.extra_data or {}).get("email"))
if user_email: if provider_email:
return user_email return provider_email
return normalize_email((sociallogin.account.extra_data or {}).get("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: def _email_verified(self, sociallogin, email: str) -> bool:
normalized = normalize_email(email) normalized = normalize_email(email)
@@ -137,6 +184,9 @@ class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
return True return True
return False 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) 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 Sign in
</button> </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> </form>
{% if oidc_enabled or social_providers %} {% 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
+136 -43
View File
@@ -3,6 +3,16 @@
{% block title %}Profile • Keywarden{% endblock %} {% block title %}Profile • Keywarden{% endblock %}
{% block content %} {% 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="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(340px,1fr)] lg:items-start">
<div class="space-y-6"> <div class="space-y-6">
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm"> <section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
@@ -231,6 +241,74 @@
</p> </p>
</section> </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"> <section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="space-y-2"> <div class="space-y-2">
<h2 class="text-base font-semibold text-gray-900">Single Sign-On</h2> <h2 class="text-base font-semibold text-gray-900">Single Sign-On</h2>
@@ -309,53 +387,68 @@
<article class="overflow-hidden rounded-xl border border-gray-200"> <article class="overflow-hidden rounded-xl border border-gray-200">
<div class="flex items-center justify-between bg-gray-50 px-4 py-3"> <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> <h3 class="text-sm font-semibold text-gray-900">GitHub</h3>
<span class="text-xs text-gray-500">GitHub, Google, Apple and more</span> {% 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 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">Unavailable</span>
{% endif %}
</div> </div>
<div class="divide-y divide-gray-100"> <div class="space-y-3 p-4">
{% for provider in social_provider_cards %} {% if github_identity %}
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-3"> <dl class="grid grid-cols-1 gap-2 text-xs text-gray-600">
<div class="min-w-0"> <div class="grid grid-cols-[96px,1fr] items-start gap-2">
<p class="text-sm font-semibold text-gray-900">{{ provider.name }}</p> <dt class="font-semibold text-gray-700">Linked email</dt>
{% if provider.linked and provider.identity %} <dd>{{ github_identity.email_at_link }}</dd>
<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>
<div class="flex items-center gap-2"> <div class="grid grid-cols-[96px,1fr] items-start gap-2">
{% if provider.linked %} <dt class="font-semibold text-gray-700">Subject</dt>
<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> <dd class="break-all font-mono">{{ github_identity.subject }}</dd>
{% 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>
{% 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>
{% endif %}
{% if provider.configured %}
<a
href="{{ provider.login_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 %}
</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>
{% endif %}
</div> </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="{{ 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"
>
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> </div>
{% endfor %} {% 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 %}
<p class="text-sm text-gray-600">
GitHub authentication is not configured in this environment.
</p>
{% endif %}
</div> </div>
</article> </article>
</div> </div>
+231 -2
View File
@@ -1,11 +1,21 @@
from __future__ import annotations 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.contrib.auth import get_user_model
from django.core.exceptions import ValidationError 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.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): class IdentityHelpersTests(TestCase):
@@ -42,3 +52,222 @@ class ExternalIdentityTests(TestCase):
email_at_link="charlie@example.com", email_at_link="charlie@example.com",
) )
self.assertIn("corporate-sso", str(identity)) 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 = [ urlpatterns = [
path("login/", views.login_view, name="login"), path("login/", views.login_view, name="login"),
path("logout/", views.logout_view, name="logout"), 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"), path("profile/", views.profile, name="profile"),
] ]
+263 -51
View File
@@ -1,17 +1,76 @@
from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.contrib import messages
from django.contrib.auth import logout from django.contrib.auth import logout
from django.contrib.auth import views as auth_views from django.contrib.auth import views as auth_views
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError 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.db import IntegrityError
from django.shortcuts import redirect, render 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 urllib.parse import urlparse
from apps.keys.certificates import issue_certificate_for_key from apps.keys.certificates import issue_certificate_for_key
from apps.keys.models import SSHKey from apps.keys.models import SSHKey
from .forms import ErasureRequestForm, SSHKeyForm from .forms import ErasureRequestForm, NativePasswordResetForm, SSHKeyForm, StyledSetPasswordForm
from .models import ErasureRequest, ExternalIdentity 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: def _oidc_provider_label() -> str:
@@ -62,7 +121,85 @@ def profile(request):
key_form.add_error("public_key", "Key already exists.") key_form.add_error("public_key", "Key already exists.")
except Exception: except Exception:
key_form.add_error(None, "Certificate issuance failed.") key_form.add_error(None, "Certificate issuance failed.")
else: 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() key_form = SSHKeyForm()
erasure_form = ErasureRequestForm(request.POST) erasure_form = ErasureRequestForm(request.POST)
if erasure_form.is_valid(): if erasure_form.is_valid():
@@ -74,61 +211,38 @@ def profile(request):
reason=erasure_form.cleaned_data["reason"].strip(), reason=erasure_form.cleaned_data["reason"].strip(),
) )
return redirect("accounts:profile") return redirect("accounts:profile")
else:
key_form = SSHKeyForm()
erasure_form = ErasureRequestForm()
else: else:
erasure_form = ErasureRequestForm() erasure_form = ErasureRequestForm()
key_form = SSHKeyForm() key_form = SSHKeyForm()
ssh_keys = SSHKey.objects.filter(user=request.user).order_by("-created_at") 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") 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() oidc_identity = external_identities.filter(provider_type=ExternalIdentity.ProviderType.OIDC).first()
social_identity_map = { github_identity = external_identities.filter(
identity.provider_id.lower(): identity provider_type=ExternalIdentity.ProviderType.SOCIAL,
for identity in external_identities.filter(provider_type=ExternalIdentity.ProviderType.SOCIAL) provider_id__iexact="github",
} ).first()
configured_social_map = { github_provider = next(
provider.get("id", "").strip().lower(): provider (
for provider in getattr(settings, "KEYWARDEN_SOCIAL_LOGIN_PROVIDERS", []) provider
if provider.get("id") for provider in getattr(settings, "KEYWARDEN_SOCIAL_LOGIN_PROVIDERS", [])
} if (provider.get("id", "").strip().lower() == "github")
),
{},
)
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"
social_provider_defaults = [ native_security = None
("github", "GitHub"), if not has_sso_identity:
("google", "Google"), native_security, _ = NativeAccountSecurity.objects.get_or_create(user=request.user)
("apple", "Apple"), idp_account_portal_url = _idp_account_portal_url()
("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,
}
)
seen_provider_ids.add(provider_id)
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,
}
)
context = { context = {
"user": request.user, "user": request.user,
@@ -137,7 +251,13 @@ def profile(request):
"oidc_identity": oidc_identity, "oidc_identity": oidc_identity,
"oidc_provider_id": getattr(settings, "KEYWARDEN_OIDC_PROVIDER_ID", "oidc"), "oidc_provider_id": getattr(settings, "KEYWARDEN_OIDC_PROVIDER_ID", "oidc"),
"oidc_issuer": getattr(settings, "KEYWARDEN_OIDC_ISSUER", ""), "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_request": erasure_request,
"erasure_form": erasure_form, "erasure_form": erasure_form,
"key_form": key_form, "key_form": key_form,
@@ -180,6 +300,8 @@ def login_view(request):
"show_native_login": show_native_login, "show_native_login": show_native_login,
"can_use_native_login": auth_mode in {"native", "hybrid"}, "can_use_native_login": auth_mode in {"native", "hybrid"},
"show_sso_choice": has_sso_options and not show_native_login, "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) )(request)
@@ -187,3 +309,93 @@ def login_view(request):
def logout_view(request): def logout_view(request):
logout(request) logout(request)
return redirect(getattr(settings, "LOGOUT_REDIRECT_URL", "/")) 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(",") for origin in os.getenv("KEYWARDEN_TRUSTED_ORIGINS", "").split(",")
if origin.strip() 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_ID = os.getenv("KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID", "").strip()
KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET = os.getenv("KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET", "").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 = env_bool(
"KEYWARDEN_SOCIAL_GITHUB_ENABLED", "KEYWARDEN_SOCIAL_GITHUB_ENABLED",
default=bool(KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID and KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET), 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_CONFIGURED = bool(
KEYWARDEN_SOCIAL_GITHUB_ENABLED KEYWARDEN_SOCIAL_GITHUB_ENABLED
and KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID and KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID
and KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET 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_SOCIAL_AUTH_ENABLED = (
KEYWARDEN_AUTH_MODE_REQUESTED == "hybrid" KEYWARDEN_AUTH_MODE_REQUESTED == "hybrid"
and any( and KEYWARDEN_SOCIAL_GITHUB_CONFIGURED
(
KEYWARDEN_SOCIAL_GOOGLE_CONFIGURED,
KEYWARDEN_SOCIAL_GITHUB_CONFIGURED,
KEYWARDEN_SOCIAL_APPLE_CONFIGURED,
)
)
) )
# Default to secure cookies and respect TLS termination headers. # Default to secure cookies and respect TLS termination headers.
@@ -129,12 +120,8 @@ if KEYWARDEN_SOCIAL_AUTH_ENABLED:
"allauth.socialaccount", "allauth.socialaccount",
] ]
) )
if KEYWARDEN_SOCIAL_GOOGLE_CONFIGURED:
INSTALLED_APPS.append("allauth.socialaccount.providers.google")
if KEYWARDEN_SOCIAL_GITHUB_CONFIGURED: if KEYWARDEN_SOCIAL_GITHUB_CONFIGURED:
INSTALLED_APPS.append("allauth.socialaccount.providers.github") INSTALLED_APPS.append("allauth.socialaccount.providers.github")
if KEYWARDEN_SOCIAL_APPLE_CONFIGURED:
INSTALLED_APPS.append("allauth.socialaccount.providers.apple")
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "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_OIDC_LOGIN_ENABLED = KEYWARDEN_OIDC_ENABLED and KEYWARDEN_AUTH_MODE_REQUESTED in {"oidc", "hybrid"}
KEYWARDEN_SOCIAL_LOGIN_PROVIDERS = [] 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: if KEYWARDEN_SOCIAL_AUTH_ENABLED and KEYWARDEN_SOCIAL_GITHUB_CONFIGURED:
KEYWARDEN_SOCIAL_LOGIN_PROVIDERS.append( KEYWARDEN_SOCIAL_LOGIN_PROVIDERS.append(
{"id": "github", "name": "GitHub", "login_url": "/accounts/sso/github/login/"} {
) "id": "github",
if KEYWARDEN_SOCIAL_AUTH_ENABLED and KEYWARDEN_SOCIAL_APPLE_CONFIGURED: "name": "GitHub",
KEYWARDEN_SOCIAL_LOGIN_PROVIDERS.append( "login_url": "/accounts/sso/github/login/",
{"id": "apple", "name": "Apple", "login_url": "/accounts/sso/apple/login/"} "connect_url": "/accounts/sso/github/login/?process=connect",
}
) )
if KEYWARDEN_SOCIAL_AUTH_ENABLED: if KEYWARDEN_SOCIAL_AUTH_ENABLED:
@@ -411,25 +395,14 @@ if KEYWARDEN_SOCIAL_AUTH_ENABLED:
ACCOUNT_UNIQUE_EMAIL = True ACCOUNT_UNIQUE_EMAIL = True
ACCOUNT_USERNAME_REQUIRED = False ACCOUNT_USERNAME_REQUIRED = False
ACCOUNT_AUTHENTICATION_METHOD = "email" ACCOUNT_AUTHENTICATION_METHOD = "email"
KEYWARDEN_SOCIAL_ALLOW_AUTO_ONBOARDING = env_bool("KEYWARDEN_SOCIAL_ALLOW_AUTO_ONBOARDING", default=False) # Social identities can only be linked to an existing Keywarden account.
SOCIALACCOUNT_AUTO_SIGNUP = KEYWARDEN_SOCIAL_ALLOW_AUTO_ONBOARDING KEYWARDEN_SOCIAL_ALLOW_AUTO_ONBOARDING = False
SOCIALACCOUNT_AUTO_SIGNUP = False
SOCIALACCOUNT_EMAIL_AUTHENTICATION = False SOCIALACCOUNT_EMAIL_AUTHENTICATION = False
SOCIALACCOUNT_ADAPTER = "apps.accounts.social_auth.KeywardenSocialAccountAdapter" SOCIALACCOUNT_ADAPTER = "apps.accounts.social_auth.KeywardenSocialAccountAdapter"
SOCIALACCOUNT_LOGIN_ON_GET = True SOCIALACCOUNT_LOGIN_ON_GET = True
SOCIALACCOUNT_PROVIDERS = {} 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: if KEYWARDEN_SOCIAL_GITHUB_CONFIGURED:
SOCIALACCOUNT_PROVIDERS["github"] = { SOCIALACCOUNT_PROVIDERS["github"] = {
"SCOPE": ["read:user", "user:email"], "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 = env_bool(
"KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL", "KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL",
default=True, 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)
}
})
})
})()