402 lines
15 KiB
Python
402 lines
15 KiB
Python
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, 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:
|
|
endpoint = (
|
|
getattr(settings, "OIDC_OP_AUTHORIZATION_ENDPOINT", "")
|
|
or getattr(settings, "OIDC_OP_DISCOVERY_ENDPOINT", "")
|
|
or getattr(settings, "OIDC_OP_USER_ENDPOINT", "")
|
|
or ""
|
|
)
|
|
if not endpoint:
|
|
return "Identity Provider"
|
|
parsed = urlparse(endpoint)
|
|
host = (parsed.hostname or "").strip().lower()
|
|
if not host:
|
|
return "Identity Provider"
|
|
parts = [part for part in host.split(".") if part and part != "www"]
|
|
if len(parts) >= 2:
|
|
return parts[-2].replace("-", " ").title()
|
|
return host.replace("-", " ").title()
|
|
|
|
|
|
@login_required(login_url="/accounts/login/")
|
|
def profile(request):
|
|
erasure_request = (
|
|
ErasureRequest.objects.filter(user=request.user).order_by("-requested_at").first()
|
|
)
|
|
can_add_key = request.user.has_perm("keys.add_sshkey")
|
|
if request.method == "POST":
|
|
form_type = request.POST.get("form_type")
|
|
if form_type == "ssh_key":
|
|
erasure_form = ErasureRequestForm()
|
|
key_form = SSHKeyForm(request.POST)
|
|
if key_form.is_valid():
|
|
if not can_add_key:
|
|
key_form.add_error(None, "You do not have permission to add SSH keys.")
|
|
else:
|
|
name = key_form.cleaned_data["name"].strip()
|
|
public_key = key_form.cleaned_data["public_key"].strip()
|
|
key = SSHKey(user=request.user, name=name)
|
|
try:
|
|
key.set_public_key(public_key)
|
|
key.save()
|
|
issue_certificate_for_key(key, created_by=request.user)
|
|
return redirect("accounts:profile")
|
|
except ValidationError as exc:
|
|
key_form.add_error("public_key", str(exc))
|
|
except IntegrityError:
|
|
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():
|
|
if erasure_request and erasure_request.status == ErasureRequest.Status.PENDING:
|
|
erasure_form.add_error(None, "You already have a pending erasure request.")
|
|
else:
|
|
ErasureRequest.objects.create(
|
|
user=request.user,
|
|
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()
|
|
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"
|
|
|
|
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,
|
|
"auth_mode": getattr(settings, "KEYWARDEN_AUTH_MODE", "hybrid"),
|
|
"oidc_enabled": bool(getattr(settings, "KEYWARDEN_OIDC_LOGIN_ENABLED", False)),
|
|
"oidc_identity": oidc_identity,
|
|
"oidc_provider_id": getattr(settings, "KEYWARDEN_OIDC_PROVIDER_ID", "oidc"),
|
|
"oidc_issuer": getattr(settings, "KEYWARDEN_OIDC_ISSUER", ""),
|
|
"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,
|
|
"ssh_keys": ssh_keys,
|
|
"can_add_key": can_add_key,
|
|
}
|
|
return render(request, "accounts/profile.html", context)
|
|
|
|
|
|
def login_view(request):
|
|
auth_mode = getattr(settings, "KEYWARDEN_AUTH_MODE", "hybrid")
|
|
oidc_login_enabled = bool(getattr(settings, "KEYWARDEN_OIDC_LOGIN_ENABLED", False))
|
|
social_providers = (
|
|
getattr(settings, "KEYWARDEN_SOCIAL_LOGIN_PROVIDERS", [])
|
|
if auth_mode == "hybrid"
|
|
else []
|
|
)
|
|
|
|
if auth_mode == "oidc" and oidc_login_enabled:
|
|
return redirect("/oidc/authenticate/")
|
|
|
|
has_sso_options = bool(
|
|
oidc_login_enabled
|
|
or social_providers
|
|
)
|
|
show_native_login = (
|
|
request.method == "POST"
|
|
or request.GET.get("native") == "1"
|
|
or auth_mode == "native"
|
|
)
|
|
|
|
# native or hybrid -> render Django's built-in login view
|
|
return auth_views.LoginView.as_view(
|
|
template_name="accounts/login.html",
|
|
extra_context={
|
|
"social_providers": social_providers,
|
|
"oidc_enabled": oidc_login_enabled,
|
|
"oidc_login_url": "/oidc/authenticate/",
|
|
"oidc_provider_label": _oidc_provider_label(),
|
|
"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)
|
|
|
|
|
|
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")
|