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

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

No files matched your search

+263 -51
View File
@@ -1,17 +1,76 @@
from datetime import timedelta
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import logout
from django.contrib.auth import views as auth_views
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
from django.core import signing
from django.core.mail import send_mail
from django.db import IntegrityError
from django.shortcuts import redirect, render
from django.template.loader import render_to_string
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from urllib.parse import urlparse
from apps.keys.certificates import issue_certificate_for_key
from apps.keys.models import SSHKey
from .forms import ErasureRequestForm, SSHKeyForm
from .models import ErasureRequest, ExternalIdentity
from .forms import ErasureRequestForm, NativePasswordResetForm, SSHKeyForm, StyledSetPasswordForm
from .identity import normalize_email
from .models import ErasureRequest, ExternalIdentity, NativeAccountSecurity
_EMAIL_VERIFICATION_SIGNER_SALT = "accounts.native-email-verification"
def _unlink_social_account(user, provider_id: str) -> None:
try:
from allauth.socialaccount.models import SocialAccount
except Exception:
return
SocialAccount.objects.filter(user=user, provider=provider_id).delete()
def _has_sso_identity(user) -> bool:
return ExternalIdentity.objects.filter(user=user).exists()
def _idp_account_portal_url() -> str:
explicit = (getattr(settings, "KEYWARDEN_IDP_ACCOUNT_PORTAL_URL", "") or "").strip()
if explicit:
return explicit
issuer = (getattr(settings, "KEYWARDEN_OIDC_ISSUER", "") or "").strip()
if issuer:
return issuer
endpoint = (
getattr(settings, "OIDC_OP_AUTHORIZATION_ENDPOINT", "")
or getattr(settings, "OIDC_OP_DISCOVERY_ENDPOINT", "")
or ""
)
if not endpoint:
return ""
parsed = urlparse(endpoint)
if not parsed.scheme or not parsed.netloc:
return ""
return f"{parsed.scheme}://{parsed.netloc}/"
def _issue_email_verification_token(user) -> str:
payload = {"uid": user.pk, "email": normalize_email(user.email)}
return signing.dumps(payload, salt=_EMAIL_VERIFICATION_SIGNER_SALT)
def _resolve_email_verification_token(token: str):
max_age = int(getattr(settings, "KEYWARDEN_EMAIL_VERIFICATION_TOKEN_MAX_AGE_SECONDS", 86400))
payload = signing.loads(token, salt=_EMAIL_VERIFICATION_SIGNER_SALT, max_age=max_age)
user_id = payload.get("uid")
email = normalize_email(payload.get("email"))
if not user_id or not email:
raise signing.BadSignature("Invalid token payload.")
return user_id, email
def _oidc_provider_label() -> str:
@@ -62,7 +121,85 @@ def profile(request):
key_form.add_error("public_key", "Key already exists.")
except Exception:
key_form.add_error(None, "Certificate issuance failed.")
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")