GitHub Social Login + Auth flow fixes. Password reset, email verification, etc.
This commit is contained in:
1 parent
f7cf22f970
commit
921b760f80
23 files changed
+1079
-220
No files matched your search
+17
-7
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
@@ -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",
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})()
|
||||
Reference in new issue
Block a user