From 921b760f804d0ce2116da815f28f6fbbee126ea9 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 16 Apr 2026 21:24:16 +0100 Subject: [PATCH] GitHub Social Login + Auth flow fixes. Password reset, email verification, etc. --- .env.example | 24 +- OIDC_SOCIAL_SETUP.md | 27 +- app/apps/accounts/admin.py | 19 +- app/apps/accounts/forms.py | 46 +++ .../migrations/0008_nativeaccountsecurity.py | 35 ++ app/apps/accounts/models.py | 19 ++ app/apps/accounts/social_auth.py | 126 ++++--- .../accounts/email_verification_email.txt | 8 + .../accounts/email_verification_subject.txt | 1 + .../accounts/templates/accounts/login.html | 10 + .../accounts/password_reset_complete.html | 18 + .../accounts/password_reset_confirm.html | 43 +++ .../accounts/password_reset_done.html | 24 ++ .../accounts/password_reset_email.txt | 8 + .../accounts/password_reset_form.html | 37 +++ .../accounts/password_reset_subject.txt | 1 + .../accounts/templates/accounts/profile.html | 179 +++++++--- app/apps/accounts/tests/test_auth.py | 233 ++++++++++++- app/apps/accounts/urls.py | 10 +- app/apps/accounts/views.py | 314 +++++++++++++++--- app/keywarden/settings/base.py | 101 ++---- app/static/account/js/account.js | 20 ++ app/static/account/js/onload.js | 12 + 23 files changed, 1087 insertions(+), 228 deletions(-) create mode 100644 app/apps/accounts/migrations/0008_nativeaccountsecurity.py create mode 100644 app/apps/accounts/templates/accounts/email_verification_email.txt create mode 100644 app/apps/accounts/templates/accounts/email_verification_subject.txt create mode 100644 app/apps/accounts/templates/accounts/password_reset_complete.html create mode 100644 app/apps/accounts/templates/accounts/password_reset_confirm.html create mode 100644 app/apps/accounts/templates/accounts/password_reset_done.html create mode 100644 app/apps/accounts/templates/accounts/password_reset_email.txt create mode 100644 app/apps/accounts/templates/accounts/password_reset_form.html create mode 100644 app/apps/accounts/templates/accounts/password_reset_subject.txt create mode 100644 app/static/account/js/account.js create mode 100644 app/static/account/js/onload.js diff --git a/.env.example b/.env.example index 2ea09ff..1c7ae52 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/OIDC_SOCIAL_SETUP.md b/OIDC_SOCIAL_SETUP.md index 0920346..4d64c57 100644 --- a/OIDC_SOCIAL_SETUP.md +++ b/OIDC_SOCIAL_SETUP.md @@ -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= -KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET= - KEYWARDEN_SOCIAL_GITHUB_ENABLED=true KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID= KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET= - -KEYWARDEN_SOCIAL_APPLE_ENABLED=true -KEYWARDEN_SOCIAL_APPLE_CLIENT_ID= -KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET= ``` 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:///accounts/sso/google/login/callback/` +Provider callback URL to register: - GitHub: `https:///accounts/sso/github/login/callback/` -- Apple: `https:///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. diff --git a/app/apps/accounts/admin.py b/app/apps/accounts/admin.py index e6fad85..fe83079 100644 --- a/app/apps/accounts/admin.py +++ b/app/apps/accounts/admin.py @@ -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 diff --git a/app/apps/accounts/forms.py b/app/apps/accounts/forms.py index d048bd7..add7c81 100644 --- a/app/apps/accounts/forms.py +++ b/app/apps/accounts/forms.py @@ -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): diff --git a/app/apps/accounts/migrations/0008_nativeaccountsecurity.py b/app/apps/accounts/migrations/0008_nativeaccountsecurity.py new file mode 100644 index 0000000..ad0a68a --- /dev/null +++ b/app/apps/accounts/migrations/0008_nativeaccountsecurity.py @@ -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", + }, + ), + ] diff --git a/app/apps/accounts/models.py b/app/apps/accounts/models.py index f671638..9eb55b5 100644 --- a/app/apps/accounts/models.py +++ b/app/apps/accounts/models.py @@ -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" diff --git a/app/apps/accounts/social_auth.py b/app/apps/accounts/social_auth.py index 208e2d9..1a4d289 100644 --- a/app/apps/accounts/social_auth.py +++ b/app/apps/accounts/social_auth.py @@ -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") diff --git a/app/apps/accounts/templates/accounts/email_verification_email.txt b/app/apps/accounts/templates/accounts/email_verification_email.txt new file mode 100644 index 0000000..3ef0c33 --- /dev/null +++ b/app/apps/accounts/templates/accounts/email_verification_email.txt @@ -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. diff --git a/app/apps/accounts/templates/accounts/email_verification_subject.txt b/app/apps/accounts/templates/accounts/email_verification_subject.txt new file mode 100644 index 0000000..a44939d --- /dev/null +++ b/app/apps/accounts/templates/accounts/email_verification_subject.txt @@ -0,0 +1 @@ +Verify your Keywarden email diff --git a/app/apps/accounts/templates/accounts/login.html b/app/apps/accounts/templates/accounts/login.html index acfe67b..a8230cf 100644 --- a/app/apps/accounts/templates/accounts/login.html +++ b/app/apps/accounts/templates/accounts/login.html @@ -82,6 +82,16 @@ > Sign in + {% if password_reset_enabled %} + + {% endif %} {% if oidc_enabled or social_providers %} diff --git a/app/apps/accounts/templates/accounts/password_reset_complete.html b/app/apps/accounts/templates/accounts/password_reset_complete.html new file mode 100644 index 0000000..09a1eb8 --- /dev/null +++ b/app/apps/accounts/templates/accounts/password_reset_complete.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% block title %}Password Updated • Keywarden{% endblock %} + +{% block content %} +
+

Password updated

+

+ Your Keywarden password has been changed successfully. +

+ + Sign in + +
+{% endblock %} diff --git a/app/apps/accounts/templates/accounts/password_reset_confirm.html b/app/apps/accounts/templates/accounts/password_reset_confirm.html new file mode 100644 index 0000000..579155e --- /dev/null +++ b/app/apps/accounts/templates/accounts/password_reset_confirm.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} + +{% block title %}Set New Password • Keywarden{% endblock %} + +{% block content %} +
+

Set a new password

+ {% if validlink %} +
+ {% csrf_token %} +
+ + {{ form.new_password1 }} +
+
+ + {{ form.new_password2 }} +
+ {% if form.errors %} +
+ Please fix the errors and try again. +
+ {% endif %} + +
+ {% else %} +

+ This password reset link is invalid or expired. +

+ + Request a new reset link + + {% endif %} +
+{% endblock %} diff --git a/app/apps/accounts/templates/accounts/password_reset_done.html b/app/apps/accounts/templates/accounts/password_reset_done.html new file mode 100644 index 0000000..ec4837c --- /dev/null +++ b/app/apps/accounts/templates/accounts/password_reset_done.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block title %}Reset Email Sent • Keywarden{% endblock %} + +{% block content %} +
+

Check your email

+

+ If a native Keywarden account exists for the submitted email, a reset link has been sent. +

+

+ SSO accounts must reset credentials in the identity provider. + {% if idp_account_portal_url %} + Open IdP portal. + {% endif %} +

+ + Return to login + +
+{% endblock %} diff --git a/app/apps/accounts/templates/accounts/password_reset_email.txt b/app/apps/accounts/templates/accounts/password_reset_email.txt new file mode 100644 index 0000000..7298ad8 --- /dev/null +++ b/app/apps/accounts/templates/accounts/password_reset_email.txt @@ -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. diff --git a/app/apps/accounts/templates/accounts/password_reset_form.html b/app/apps/accounts/templates/accounts/password_reset_form.html new file mode 100644 index 0000000..ad3e833 --- /dev/null +++ b/app/apps/accounts/templates/accounts/password_reset_form.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} + +{% block title %}Reset Password • Keywarden{% endblock %} + +{% block content %} +
+

Reset your password

+

+ Enter your Keywarden account email to receive a password reset link. +

+
+ {% csrf_token %} +
+ + {{ form.email }} +
+ {% if form.errors %} +
+ Please correct the highlighted issues and retry. +
+ {% endif %} + +
+

+ Accounts managed by SSO do not use local password reset. + {% if idp_account_portal_url %} + Use your identity provider portal: + {{ idp_account_portal_url }}. + {% endif %} +

+
+{% endblock %} diff --git a/app/apps/accounts/templates/accounts/password_reset_subject.txt b/app/apps/accounts/templates/accounts/password_reset_subject.txt new file mode 100644 index 0000000..4ce3eb4 --- /dev/null +++ b/app/apps/accounts/templates/accounts/password_reset_subject.txt @@ -0,0 +1 @@ +Keywarden password reset diff --git a/app/apps/accounts/templates/accounts/profile.html b/app/apps/accounts/templates/accounts/profile.html index 9a6a06f..1b07ad1 100644 --- a/app/apps/accounts/templates/accounts/profile.html +++ b/app/apps/accounts/templates/accounts/profile.html @@ -3,6 +3,16 @@ {% block title %}Profile • Keywarden{% endblock %} {% block content %} + {% if messages %} +
+ {% for message in messages %} + + {% endfor %} +
+ {% endif %} +
@@ -231,6 +241,74 @@

+
+
+

Native account security

+

Password reset and email verification for Keywarden-native credentials.

+
+ {% if native_security_enabled %} +
+
+
+ Email verification + {% if native_account_security and native_account_security.email_verified %} + Verified + {% else %} + Unverified + {% endif %} +
+ {% if email_verification_enabled %} + {% if native_account_security and native_account_security.email_verified %} +

+ Verified at {{ native_account_security.email_verified_at|date:"M j, Y H:i" }}. +

+ {% else %} +

+ Send a verification link to {{ user.email }}. +

+
+ {% csrf_token %} + + +
+ {% endif %} + {% else %} +

Email verification is disabled in this environment.

+ {% endif %} +
+
+

Password reset

+

+ Reset your Keywarden-native password via secure email token flow. +

+ + Start password reset + +
+
+ {% else %} +
+

+ Native password reset and local email verification are disabled because this account uses SSO. +

+

+ Manage password and email verification in your identity provider. + {% if idp_account_portal_url %} + Open IdP account portal. + {% endif %} +

+
+ {% endif %} +
+

Single Sign-On

@@ -309,53 +387,68 @@
-

Social Providers

- GitHub, Google, Apple and more +

GitHub

+ {% if github_identity %} + Linked + {% elif github_social_configured %} + Ready to link + {% else %} + Unavailable + {% endif %}
-
- {% for provider in social_provider_cards %} -
-
-

{{ provider.name }}

- {% if provider.linked and provider.identity %} -

- 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 %} -

- {% elif provider.configured %} -

Configured and ready to link.

- {% else %} -

Placeholder. Configure provider credentials to enable linking.

- {% endif %} +
+ {% if github_identity %} +
+
+
Linked email
+
{{ github_identity.email_at_link }}
-
- {% if provider.linked %} - Linked - {% elif provider.configured %} - Ready - {% else %} - Placeholder - {% endif %} - - {% if provider.configured %} - - {% if provider.linked %}Re-link{% else %}Link{% endif %} - - {% else %} - - {% endif %} +
+
Subject
+
{{ github_identity.subject }}
+ {% if github_identity.last_login_at %} +
+
Last login
+
{{ github_identity.last_login_at|date:"M j, Y H:i" }}
+
+ {% endif %} +
+
+ {% if github_social_connect_url %} + + Re-link GitHub + + {% endif %} +
+ {% csrf_token %} + + +
- {% endfor %} + {% elif github_social_configured %} +

+ Link GitHub while signed in. GitHub login is only available after linking to an existing Keywarden account. +

+ + Link GitHub + + {% else %} +

+ GitHub authentication is not configured in this environment. +

+ {% endif %}
diff --git a/app/apps/accounts/tests/test_auth.py b/app/apps/accounts/tests/test_auth.py index fc74219..cce7890 100644 --- a/app/apps/accounts/tests/test_auth.py +++ b/app/apps/accounts/tests/test_auth.py @@ -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()) diff --git a/app/apps/accounts/urls.py b/app/apps/accounts/urls.py index 1567d86..944928a 100644 --- a/app/apps/accounts/urls.py +++ b/app/apps/accounts/urls.py @@ -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///", + 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//", views.email_verify_view, name="email_verify"), path("profile/", views.profile, name="profile"), ] - diff --git a/app/apps/accounts/views.py b/app/apps/accounts/views.py index 5da2ff6..3c21995 100644 --- a/app/apps/accounts/views.py +++ b/app/apps/accounts/views.py @@ -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.") - 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() 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 - for provider in getattr(settings, "KEYWARDEN_SOCIAL_LOGIN_PROVIDERS", []) - if provider.get("id") - } + 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", "").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 = [ - ("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, - } - ) - 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, - } - ) + 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") diff --git a/app/keywarden/settings/base.py b/app/keywarden/settings/base.py index b1e5969..d8ddc22 100644 --- a/app/keywarden/settings/base.py +++ b/app/keywarden/settings/base.py @@ -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, diff --git a/app/static/account/js/account.js b/app/static/account/js/account.js new file mode 100644 index 0000000..fbc5135 --- /dev/null +++ b/app/static/account/js/account.js @@ -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 + } + } +})() diff --git a/app/static/account/js/onload.js b/app/static/account/js/onload.js new file mode 100644 index 0000000..1a224c9 --- /dev/null +++ b/app/static/account/js/onload.js @@ -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) + } + }) + }) +})()