Certificate generation and sync, implemented proper grant and revocation flows. Pubkey uploading. Added openssh-client to Dockerfile

This commit is contained in:
2026-01-26 23:27:18 +00:00
parent cdaceb1cf7
commit 664e7be9f0
23 changed files with 1119 additions and 66 deletions

View File

@@ -14,3 +14,26 @@ class ErasureRequestForm(forms.Form):
min_length=10,
max_length=2000,
)
class SSHKeyForm(forms.Form):
name = forms.CharField(
label="Key name",
max_length=128,
widget=forms.TextInput(
attrs={
"placeholder": "MacBook Pro",
"class": "w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-purple-600 focus:outline-none focus:ring-1 focus:ring-purple-600",
}
),
)
public_key = forms.CharField(
label="SSH public key",
widget=forms.Textarea(
attrs={
"rows": 4,
"placeholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB... you@host",
"class": "w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-purple-600 focus:outline-none focus:ring-1 focus:ring-purple-600",
}
),
)

View File

@@ -78,7 +78,7 @@ class ErasureRequest(models.Model):
from guardian.models import UserObjectPermission
from apps.access.models import AccessRequest
from apps.keys.models import SSHKey
from apps.keys.models import SSHCertificate, SSHKey
user = self.user
token = uuid.uuid4().hex
@@ -113,6 +113,7 @@ class ErasureRequest(models.Model):
UserObjectPermission.objects.filter(user=user).delete()
SSHKey.objects.filter(user=user, is_active=True).update(is_active=False, revoked_at=now)
SSHCertificate.objects.filter(user=user, is_active=True).update(is_active=False, revoked_at=now)
AccessRequest.objects.filter(requester=user).update(reason="[redacted]")
AccessRequest.objects.filter(
requester=user,

View File

@@ -46,6 +46,152 @@
</div>
</div>
<div class="mt-6 rounded-xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
<h2 class="text-base font-semibold tracking-tight text-gray-900">SSH certificates</h2>
<p class="mt-2 text-sm text-gray-600">
Upload your SSH public key to receive a signed certificate for server access.
</p>
{% if can_add_key %}
<form method="post" class="mt-4 space-y-3">
{% csrf_token %}
<input type="hidden" name="form_type" value="ssh_key">
<div>
<label for="{{ key_form.name.id_for_label }}" class="block text-sm font-medium text-gray-700">
Key name
</label>
<div class="mt-1">
{{ key_form.name }}
</div>
{% if key_form.name.errors %}
<p class="mt-1 text-sm text-red-600">{{ key_form.name.errors|striptags }}</p>
{% endif %}
</div>
<div>
<label for="{{ key_form.public_key.id_for_label }}" class="block text-sm font-medium text-gray-700">
SSH public key
</label>
<div class="mt-1">
{{ key_form.public_key }}
</div>
{% if key_form.public_key.errors %}
<p class="mt-1 text-sm text-red-600">{{ key_form.public_key.errors|striptags }}</p>
{% endif %}
</div>
{% if key_form.non_field_errors %}
<p class="text-sm text-red-600">{{ key_form.non_field_errors|striptags }}</p>
{% endif %}
<button type="submit" class="inline-flex items-center rounded-md bg-purple-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-purple-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-600">
Upload key
</button>
</form>
{% else %}
<p class="mt-4 text-sm text-gray-600">You do not have permission to add SSH keys.</p>
{% endif %}
{% if ssh_keys %}
<div class="mt-6 divide-y divide-gray-200">
{% for key in ssh_keys %}
<div class="flex items-center justify-between py-4">
<div>
<p class="text-sm font-semibold text-gray-900">{{ key.name }}</p>
<p class="text-xs text-gray-500">{{ key.fingerprint }}</p>
</div>
<div class="flex items-center gap-3">
{% if key.is_active %}
<div class="inline-flex overflow-hidden rounded-md shadow-sm">
<button
type="button"
class="inline-flex items-center rounded-l-md bg-purple-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-purple-700"
data-download-url="/api/v1/keys/{{ key.id }}/certificate"
>
Download
</button>
<button
type="button"
class="inline-flex items-center rounded-r-md border border-l-0 border-gray-200 bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-200"
data-download-url="/api/v1/keys/{{ key.id }}/certificate.sha256"
>
Hash
</button>
</div>
<button
type="button"
class="inline-flex items-center rounded-md bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-700 js-regenerate-cert"
data-key-id="{{ key.id }}"
>
Regenerate
</button>
{% else %}
<span class="text-xs font-semibold text-gray-500">Revoked</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="mt-4 text-sm text-gray-600">No SSH keys uploaded yet.</p>
{% endif %}
</div>
<script>
(function () {
function getCookie(name) {
var value = "; " + document.cookie;
var parts = value.split("; " + name + "=");
if (parts.length === 2) {
return parts.pop().split(";").shift();
}
return "";
}
function handleDownload(event) {
var button = event.currentTarget;
var url = button.getAttribute("data-download-url");
if (!url) {
return;
}
window.location.href = url;
}
function handleRegenerate(event) {
var button = event.currentTarget;
var keyId = button.getAttribute("data-key-id");
if (!keyId) {
return;
}
if (!window.confirm("Regenerate the certificate for this key?")) {
return;
}
var csrf = getCookie("csrftoken");
fetch("/api/v1/keys/" + keyId + "/certificate", {
method: "POST",
credentials: "same-origin",
headers: {
"X-CSRFToken": csrf,
},
})
.then(function (response) {
if (!response.ok) {
throw new Error("Certificate regeneration failed.");
}
window.location.href = "/api/v1/keys/" + keyId + "/certificate";
})
.catch(function (err) {
window.alert(err.message);
});
}
var downloadButtons = document.querySelectorAll("[data-download-url]");
for (var i = 0; i < downloadButtons.length; i += 1) {
downloadButtons[i].addEventListener("click", handleDownload);
}
var buttons = document.querySelectorAll(".js-regenerate-cert");
for (var j = 0; j < buttons.length; j += 1) {
buttons[j].addEventListener("click", handleRegenerate);
}
})();
</script>
<div class="mt-6 rounded-xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
<h2 class="text-base font-semibold tracking-tight text-gray-900">Data erasure request</h2>
<p class="mt-2 text-sm text-gray-600">
@@ -81,6 +227,7 @@
{% if not erasure_request or erasure_request.status != "pending" %}
<form method="post" class="mt-4 space-y-3">
{% csrf_token %}
<input type="hidden" name="form_type" value="erasure">
<div>
<label for="{{ erasure_form.reason.id_for_label }}" class="block text-sm font-medium text-gray-700">
Reason for request

View File

@@ -2,9 +2,14 @@ from django.conf import settings
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.db import IntegrityError
from django.shortcuts import redirect, render
from .forms import ErasureRequestForm
from apps.keys.certificates import issue_certificate_for_key
from apps.keys.models import SSHKey
from .forms import ErasureRequestForm, SSHKeyForm
from .models import ErasureRequest
@@ -13,25 +18,55 @@ 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 = ErasureRequestForm(request.POST)
if form.is_valid():
if erasure_request and erasure_request.status == ErasureRequest.Status.PENDING:
form.add_error(None, "You already have a pending erasure request.")
else:
ErasureRequest.objects.create(
user=request.user,
reason=form.cleaned_data["reason"].strip(),
)
return redirect("accounts:profile")
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.")
else:
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:
form = ErasureRequestForm()
erasure_form = ErasureRequestForm()
key_form = SSHKeyForm()
ssh_keys = SSHKey.objects.filter(user=request.user).order_by("-created_at")
context = {
"user": request.user,
"auth_mode": getattr(settings, "KEYWARDEN_AUTH_MODE", "hybrid"),
"erasure_request": erasure_request,
"erasure_form": form,
"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)

View File

@@ -8,7 +8,7 @@ except ImportError: # Fallback for older Unfold builds without guardian admin s
class GuardedModelAdmin(GuardianGuardedModelAdmin, UnfoldModelAdmin):
pass
from .models import SSHKey
from .models import SSHCertificate, SSHCertificateAuthority, SSHKey
@admin.register(SSHKey)
@@ -17,3 +17,21 @@ class SSHKeyAdmin(GuardedModelAdmin):
list_filter = ("is_active", "key_type")
search_fields = ("name", "user__username", "user__email", "fingerprint")
ordering = ("-created_at",)
@admin.register(SSHCertificateAuthority)
class SSHCertificateAuthorityAdmin(admin.ModelAdmin):
list_display = ("name", "fingerprint", "is_active", "created_at", "revoked_at")
list_filter = ("is_active",)
search_fields = ("name", "fingerprint")
readonly_fields = ("created_at", "revoked_at", "fingerprint", "public_key", "private_key")
ordering = ("-created_at",)
@admin.register(SSHCertificate)
class SSHCertificateAdmin(admin.ModelAdmin):
list_display = ("id", "user", "key", "serial", "is_active", "valid_before", "created_at")
list_filter = ("is_active",)
search_fields = ("user__username", "user__email", "serial")
readonly_fields = ("created_at", "revoked_at", "certificate")
ordering = ("-created_at",)

View File

@@ -0,0 +1,148 @@
from __future__ import annotations
import os
import re
import secrets
import subprocess
import tempfile
from datetime import timedelta
from django.conf import settings
from django.utils import timezone
from .models import SSHCertificate, SSHCertificateAuthority, SSHKey
from .utils import render_system_username
def get_active_ca(created_by=None) -> SSHCertificateAuthority:
ca = (
SSHCertificateAuthority.objects.filter(is_active=True, revoked_at__isnull=True)
.order_by("-created_at")
.first()
)
if not ca:
ca = SSHCertificateAuthority(created_by=created_by)
ca.ensure_material()
ca.save()
return ca
def issue_certificate_for_key(key: SSHKey, created_by=None) -> SSHCertificate:
if not key or not key.user_id:
raise ValueError("key must have a user")
ca = get_active_ca(created_by=created_by)
principal = render_system_username(key.user.username, key.user_id)
now = timezone.now()
valid_before = now + timedelta(days=settings.KEYWARDEN_USER_CERT_VALIDITY_DAYS)
serial = secrets.randbits(63)
safe_name = _sanitize_label(key.name or "key")
identity = f"keywarden-cert-{key.user_id}-{safe_name}-{key.id}"
cert_text = _sign_public_key(
ca_private_key=ca.private_key,
ca_public_key=ca.public_key,
public_key=key.public_key,
identity=identity,
principal=principal,
serial=serial,
validity_days=settings.KEYWARDEN_USER_CERT_VALIDITY_DAYS,
comment=identity,
)
cert, _ = SSHCertificate.objects.update_or_create(
key=key,
defaults={
"user": key.user,
"certificate": cert_text,
"serial": serial,
"principals": [principal],
"valid_after": now,
"valid_before": valid_before,
"revoked_at": None,
"is_active": True,
},
)
return cert
def revoke_certificate_for_key(key: SSHKey) -> None:
if not key:
return
try:
cert = key.certificate
except SSHCertificate.DoesNotExist:
return
cert.revoke()
cert.save(update_fields=["is_active", "revoked_at"])
def _sign_public_key(
ca_private_key: str,
ca_public_key: str,
public_key: str,
identity: str,
principal: str,
serial: int,
validity_days: int,
comment: str,
) -> str:
if not ca_private_key or not ca_public_key:
raise RuntimeError("CA material missing")
with tempfile.TemporaryDirectory() as tmpdir:
ca_path = os.path.join(tmpdir, "user_ca")
pubkey_path = os.path.join(tmpdir, "user.pub")
_write_file(ca_path, ca_private_key, 0o600)
_write_file(ca_path + ".pub", ca_public_key.strip() + "\n", 0o644)
pubkey_with_comment = _ensure_comment(public_key, comment)
_write_file(pubkey_path, pubkey_with_comment + "\n", 0o644)
cmd = [
"ssh-keygen",
"-s",
ca_path,
"-I",
identity,
"-n",
principal,
"-V",
f"+{validity_days}d",
"-z",
str(serial),
pubkey_path,
]
try:
result = subprocess.run(cmd, check=True, capture_output=True)
except FileNotFoundError as exc:
raise RuntimeError("ssh-keygen not available") from exc
except subprocess.CalledProcessError as exc:
raise RuntimeError(f"ssh-keygen failed: {exc.stderr.decode('utf-8', 'ignore')}") from exc
cert_path = pubkey_path
if cert_path.endswith(".pub"):
cert_path = cert_path[: -len(".pub")]
cert_path += "-cert.pub"
if not os.path.exists(cert_path):
stderr = result.stderr.decode("utf-8", "ignore")
raise RuntimeError(f"ssh-keygen output missing: {cert_path} {stderr}")
with open(cert_path, "r", encoding="utf-8") as handle:
return handle.read().strip()
def _ensure_comment(public_key: str, comment: str) -> str:
parts = (public_key or "").strip().split()
if len(parts) < 2:
return public_key.strip()
key_type, key_b64 = parts[0], parts[1]
if not comment:
return f"{key_type} {key_b64}"
return f"{key_type} {key_b64} {comment}"
def _sanitize_label(value: str) -> str:
cleaned = re.sub(r"[^a-zA-Z0-9_-]+", "-", (value or "").strip())
cleaned = cleaned.strip("-_")
if cleaned:
return cleaned.lower()
return "key"
def _write_file(path: str, data: str, mode: int) -> None:
with open(path, "w", encoding="utf-8") as handle:
handle.write(data)
os.chmod(path, mode)

View File

@@ -0,0 +1,86 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
("keys", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="SSHCertificateAuthority",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("name", models.CharField(default="Keywarden User SSH CA", max_length=128)),
("public_key", models.TextField(blank=True)),
("private_key", models.TextField(blank=True)),
("fingerprint", models.CharField(blank=True, max_length=128)),
("created_at", models.DateTimeField(default=django.utils.timezone.now, editable=False)),
("revoked_at", models.DateTimeField(blank=True, null=True)),
("is_active", models.BooleanField(db_index=True, default=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="ssh_certificate_authorities",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "SSH certificate authority",
"verbose_name_plural": "SSH certificate authorities",
"ordering": ["-created_at"],
},
),
migrations.CreateModel(
name="SSHCertificate",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("certificate", models.TextField()),
("serial", models.BigIntegerField()),
("principals", models.JSONField(blank=True, default=list)),
("valid_after", models.DateTimeField()),
("valid_before", models.DateTimeField()),
("created_at", models.DateTimeField(default=django.utils.timezone.now, editable=False)),
("revoked_at", models.DateTimeField(blank=True, null=True)),
("is_active", models.BooleanField(db_index=True, default=True)),
(
"key",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="certificate",
to="keys.sshkey",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ssh_certificates",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "SSH certificate",
"verbose_name_plural": "SSH certificates",
"ordering": ["-created_at"],
},
),
migrations.AddIndex(
model_name="sshcertificate",
index=models.Index(fields=["user", "is_active"], name="keys_cert_user_active_idx"),
),
migrations.AddIndex(
model_name="sshcertificate",
index=models.Index(fields=["valid_before"], name="keys_cert_valid_before_idx"),
),
]

View File

@@ -3,6 +3,9 @@ from __future__ import annotations
import base64
import binascii
import hashlib
import os
import subprocess
import tempfile
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -61,6 +64,107 @@ class SSHKey(models.Model):
def revoke(self) -> None:
self.is_active = False
self.revoked_at = timezone.now()
try:
cert = self.certificate
except SSHCertificate.DoesNotExist:
return
cert.revoke()
cert.save(update_fields=["is_active", "revoked_at"])
def __str__(self) -> str:
return f"{self.name} ({self.user_id})"
class SSHCertificateAuthority(models.Model):
name = models.CharField(max_length=128, default="Keywarden User SSH CA")
public_key = models.TextField(blank=True)
private_key = models.TextField(blank=True)
fingerprint = models.CharField(max_length=128, blank=True)
created_at = models.DateTimeField(default=timezone.now, editable=False)
revoked_at = models.DateTimeField(null=True, blank=True)
is_active = models.BooleanField(default=True, db_index=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="ssh_certificate_authorities",
)
class Meta:
verbose_name = "SSH certificate authority"
verbose_name_plural = "SSH certificate authorities"
ordering = ["-created_at"]
def __str__(self) -> str:
status = "active" if self.is_active and not self.revoked_at else "revoked"
return f"{self.name} ({status})"
def revoke(self) -> None:
self.is_active = False
self.revoked_at = timezone.now()
def ensure_material(self) -> None:
if self.public_key and self.private_key:
if not self.fingerprint:
_, _, fingerprint = parse_public_key(self.public_key)
self.fingerprint = fingerprint
return
with tempfile.TemporaryDirectory() as tmpdir:
key_path = os.path.join(tmpdir, "keywarden_user_ca")
cmd = [
"ssh-keygen",
"-t",
"ed25519",
"-f",
key_path,
"-C",
self.name,
"-N",
"",
]
try:
subprocess.run(cmd, check=True, capture_output=True)
except FileNotFoundError as exc:
raise RuntimeError("ssh-keygen not available") from exc
except subprocess.CalledProcessError as exc:
raise RuntimeError(f"ssh-keygen failed: {exc.stderr.decode('utf-8', 'ignore')}") from exc
with open(key_path, "r", encoding="utf-8") as handle:
self.private_key = handle.read()
with open(key_path + ".pub", "r", encoding="utf-8") as handle:
self.public_key = handle.read().strip()
_, _, fingerprint = parse_public_key(self.public_key)
self.fingerprint = fingerprint
class SSHCertificate(models.Model):
key = models.OneToOneField(
SSHKey, on_delete=models.CASCADE, related_name="certificate"
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ssh_certificates"
)
certificate = models.TextField()
serial = models.BigIntegerField()
principals = models.JSONField(default=list, blank=True)
valid_after = models.DateTimeField()
valid_before = models.DateTimeField()
created_at = models.DateTimeField(default=timezone.now, editable=False)
revoked_at = models.DateTimeField(null=True, blank=True)
is_active = models.BooleanField(default=True, db_index=True)
class Meta:
verbose_name = "SSH certificate"
verbose_name_plural = "SSH certificates"
indexes = [
models.Index(fields=["user", "is_active"], name="keys_cert_user_active_idx"),
models.Index(fields=["valid_before"], name="keys_cert_valid_before_idx"),
]
ordering = ["-created_at"]
def revoke(self) -> None:
self.is_active = False
self.revoked_at = timezone.now()
def __str__(self) -> str:
return f"{self.user_id} ({self.serial})"

29
app/apps/keys/utils.py Normal file
View File

@@ -0,0 +1,29 @@
from __future__ import annotations
import re
from django.conf import settings
MAX_USERNAME_LEN = 32
_SANITIZE_RE = re.compile(r"[^a-z0-9_-]")
def render_system_username(username: str, user_id: int) -> str:
template = settings.KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE
raw = template.replace("{{username}}", username or "")
raw = raw.replace("{{user_id}}", str(user_id))
cleaned = sanitize_username(raw)
if len(cleaned) > MAX_USERNAME_LEN:
cleaned = cleaned[:MAX_USERNAME_LEN]
if cleaned:
return cleaned
return f"kw_{user_id}"
def sanitize_username(raw: str) -> str:
raw = (raw or "").lower()
raw = _SANITIZE_RE.sub("_", raw)
raw = raw.strip("-_")
if raw.startswith("-"):
return "kw" + raw
return raw

View File

@@ -46,9 +46,35 @@
<div class="flex items-center justify-between">
<dt>Certificate</dt>
<dd class="font-medium text-gray-900">
<span class="inline-flex items-center rounded-full border border-gray-200 bg-gray-50 px-2 py-1 text-xs font-semibold text-gray-500">
Download coming soon
</span>
{% if certificate_key_id %}
<div class="flex items-center gap-2">
<div class="inline-flex overflow-hidden rounded-md shadow-sm">
<button
type="button"
class="inline-flex items-center rounded-l-md bg-purple-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-purple-700"
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate"
>
Download
</button>
<button
type="button"
class="inline-flex items-center rounded-r-md border border-l-0 border-gray-200 bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-200"
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate.sha256"
>
Hash
</button>
</div>
<button
type="button"
class="inline-flex items-center rounded-md bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-700 js-regenerate-cert"
data-key-id="{{ certificate_key_id }}"
>
Regenerate
</button>
</div>
{% else %}
<span class="text-xs font-semibold text-gray-500">Upload a key to download</span>
{% endif %}
</dd>
</div>
<div class="flex items-center justify-between">
@@ -93,4 +119,62 @@
</div>
</section>
</div>
<script>
(function () {
function getCookie(name) {
var value = "; " + document.cookie;
var parts = value.split("; " + name + "=");
if (parts.length === 2) {
return parts.pop().split(";").shift();
}
return "";
}
function handleDownload(event) {
var button = event.currentTarget;
var url = button.getAttribute("data-download-url");
if (!url) {
return;
}
window.location.href = url;
}
function handleRegenerate(event) {
var button = event.currentTarget;
var keyId = button.getAttribute("data-key-id");
if (!keyId) {
return;
}
if (!window.confirm("Regenerate the certificate for this key?")) {
return;
}
var csrf = getCookie("csrftoken");
fetch("/api/v1/keys/" + keyId + "/certificate", {
method: "POST",
credentials: "same-origin",
headers: {
"X-CSRFToken": csrf,
},
})
.then(function (response) {
if (!response.ok) {
throw new Error("Certificate regeneration failed.");
}
window.location.href = "/api/v1/keys/" + keyId + "/certificate";
})
.catch(function (err) {
window.alert(err.message);
});
}
var downloadButtons = document.querySelectorAll("[data-download-url]");
for (var i = 0; i < downloadButtons.length; i += 1) {
downloadButtons[i].addEventListener("click", handleDownload);
}
var buttons = document.querySelectorAll(".js-regenerate-cert");
for (var j = 0; j < buttons.length; j += 1) {
buttons[j].addEventListener("click", handleRegenerate);
}
})();
</script>
{% endblock %}

View File

@@ -8,6 +8,7 @@ from django.utils import timezone
from guardian.shortcuts import get_objects_for_user, get_perms
from apps.access.models import AccessRequest
from apps.keys.models import SSHKey
from apps.servers.models import Server, ServerAccount
@@ -78,6 +79,9 @@ def detail(request, server_id: int):
)
account = ServerAccount.objects.filter(server=server, user=request.user).first()
active_key = (
SSHKey.objects.filter(user=request.user, is_active=True).order_by("-created_at").first()
)
context = {
"server": server,
"expires_at": access.expires_at if access else None,
@@ -85,5 +89,6 @@ def detail(request, server_id: int):
"account_present": account.is_present if account else None,
"account_synced_at": account.last_synced_at if account else None,
"system_username": account.system_username if account else None,
"certificate_key_id": active_key.id if active_key else None,
}
return render(request, "servers/detail.html", context)