feat: enhance admin dashboard with chain integrity verification

- Add chain integrity verification system for document signatures
- Implement VerifyDocumentChainIntegrity method in AdminRepository
- Add ChainIntegrityResult struct to track validation status
- Display chain integrity status in admin document details page
- Add API endpoint for programmatic chain integrity checks
- Add admin access link in main interface for authorized users
- Update templates to show integrity verification results
- Add admin configuration to docker-compose environment
This commit is contained in:
Benjamin
2025-09-26 21:27:17 +02:00
parent 53aa233f66
commit 76c2e8de4e
10 changed files with 233 additions and 44 deletions

View File

@@ -35,6 +35,7 @@ services:
ACKIFY_OAUTH_COOKIE_SECRET: "${ACKIFY_OAUTH_COOKIE_SECRET}"
ACKIFY_ED25519_PRIVATE_KEY: "${ACKIFY_ED25519_PRIVATE_KEY}"
ACKIFY_LISTEN_ADDR: ":8080"
ACKIFY_ADMIN_EMAILS: "${ACKIFY_ADMIN_EMAILS}"
depends_on:
ackify-migrate:
condition: service_completed_successfully

View File

@@ -108,6 +108,89 @@ func (r *AdminRepository) ListSignaturesByDoc(ctx context.Context, docID string)
return signatures, nil
}
// VerifyDocumentChainIntegrity vérifie l'intégrité de la chaîne pour un document donné
func (r *AdminRepository) VerifyDocumentChainIntegrity(ctx context.Context, docID string) (*ChainIntegrityResult, error) {
signatures, err := r.ListSignaturesByDoc(ctx, docID)
if err != nil {
return nil, fmt.Errorf("failed to get signatures for document %s: %w", docID, err)
}
return r.verifyChainIntegrity(signatures), nil
}
// ChainIntegrityResult contient le résultat de la vérification d'intégrité
type ChainIntegrityResult struct {
IsValid bool `json:"is_valid"`
TotalSigs int `json:"total_signatures"`
ValidSigs int `json:"valid_signatures"`
InvalidSigs int `json:"invalid_signatures"`
Errors []string `json:"errors,omitempty"`
DocID string `json:"doc_id"`
}
// verifyChainIntegrity vérifie l'intégrité de la chaîne de signatures
func (r *AdminRepository) verifyChainIntegrity(signatures []*models.Signature) *ChainIntegrityResult {
result := &ChainIntegrityResult{
IsValid: true,
TotalSigs: len(signatures),
ValidSigs: 0,
InvalidSigs: 0,
Errors: []string{},
}
if len(signatures) == 0 {
return result
}
// Trier par ID pour vérifier dans l'ordre chronologique
sortedSigs := make([]*models.Signature, len(signatures))
copy(sortedSigs, signatures)
// Tri manuel par ID (ordre croissant)
for i := 0; i < len(sortedSigs)-1; i++ {
for j := i + 1; j < len(sortedSigs); j++ {
if sortedSigs[i].ID > sortedSigs[j].ID {
sortedSigs[i], sortedSigs[j] = sortedSigs[j], sortedSigs[i]
}
}
}
result.DocID = sortedSigs[0].DocID
// Vérification de la première signature (genesis)
firstSig := sortedSigs[0]
if firstSig.PrevHash != nil && *firstSig.PrevHash != "" {
result.IsValid = false
result.InvalidSigs++
result.Errors = append(result.Errors, fmt.Sprintf("Genesis signature ID:%d has prev_hash (should be null)", firstSig.ID))
} else {
result.ValidSigs++
}
// Vérification des signatures suivantes
for i := 1; i < len(sortedSigs); i++ {
currentSig := sortedSigs[i]
prevSig := sortedSigs[i-1]
expectedPrevHash := prevSig.ComputeRecordHash()
if currentSig.PrevHash == nil {
result.IsValid = false
result.InvalidSigs++
result.Errors = append(result.Errors, fmt.Sprintf("Signature ID:%d missing prev_hash", currentSig.ID))
} else if *currentSig.PrevHash != expectedPrevHash {
result.IsValid = false
result.InvalidSigs++
result.Errors = append(result.Errors, fmt.Sprintf("Signature ID:%d has invalid prev_hash: expected %s, got %s",
currentSig.ID, expectedPrevHash[:12]+"...", (*currentSig.PrevHash)[:12]+"..."))
} else {
result.ValidSigs++
}
}
return result
}
// Close closes the database connection
func (r *AdminRepository) Close() error {
if r.db != nil {

View File

@@ -1,6 +1,7 @@
package admin
import (
"encoding/json"
"html/template"
"net/http"
@@ -54,16 +55,20 @@ func (h *AdminHandlers) HandleDashboard(w http.ResponseWriter, r *http.Request)
User *models.User
BaseURL string
Documents []database.DocumentAgg
DocID *string
IsAdmin bool
}{
TemplateName: "admin_dashboard",
User: user,
BaseURL: h.baseURL,
Documents: documents,
DocID: nil,
IsAdmin: true, // L'utilisateur est forcément admin pour accéder à cette page
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.templates.ExecuteTemplate(w, "base", data); err != nil {
http.Error(w, "Template error", http.StatusInternalServerError)
http.Error(w, "Template error: "+err.Error(), http.StatusInternalServerError)
return
}
}
@@ -90,23 +95,64 @@ func (h *AdminHandlers) HandleDocumentDetails(w http.ResponseWriter, r *http.Req
return
}
// Vérifier l'intégrité de la chaîne pour ce document
chainIntegrity, err := h.adminRepo.VerifyDocumentChainIntegrity(ctx, docID)
if err != nil {
// Log l'erreur mais continue l'affichage
chainIntegrity = &database.ChainIntegrityResult{
IsValid: false,
TotalSigs: len(signatures),
ValidSigs: 0,
InvalidSigs: len(signatures),
Errors: []string{"Failed to verify chain integrity: " + err.Error()},
DocID: docID,
}
}
data := struct {
TemplateName string
User *models.User
BaseURL string
DocID string
Signatures []*models.Signature
TemplateName string
User *models.User
BaseURL string
DocID *string
Signatures []*models.Signature
ChainIntegrity *database.ChainIntegrityResult
IsAdmin bool
}{
TemplateName: "admin_doc_details",
User: user,
BaseURL: h.baseURL,
DocID: docID,
Signatures: signatures,
TemplateName: "admin_doc_details",
User: user,
BaseURL: h.baseURL,
DocID: &docID,
Signatures: signatures,
ChainIntegrity: chainIntegrity,
IsAdmin: true, // L'utilisateur est forcément admin pour accéder à cette page
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.templates.ExecuteTemplate(w, "base", data); err != nil {
http.Error(w, "Template error", http.StatusInternalServerError)
http.Error(w, "Template error: "+err.Error(), http.StatusInternalServerError)
return
}
}
// HandleChainIntegrityAPI handles GET /admin/api/chain-integrity/{docID} - returns JSON
func (h *AdminHandlers) HandleChainIntegrityAPI(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
docID := chi.URLParam(r, "docID")
if docID == "" {
http.Error(w, "Document ID required", http.StatusBadRequest)
return
}
result, err := h.adminRepo.VerifyDocumentChainIntegrity(ctx, docID)
if err != nil {
http.Error(w, "Failed to verify chain integrity", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(result); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}

View File

@@ -65,3 +65,27 @@ func (m *AdminMiddleware) isAdminUser(user *models.User) bool {
return false
}
// IsAdminUser is a public function to check if a user is admin (for templates)
func IsAdminUser(user *models.User) bool {
if user == nil {
return false
}
adminEmails := os.Getenv("ACKIFY_ADMIN_EMAILS")
if adminEmails == "" {
return false
}
userEmail := strings.ToLower(strings.TrimSpace(user.Email))
emails := strings.Split(adminEmails, ",")
for _, email := range emails {
adminEmail := strings.ToLower(strings.TrimSpace(email))
if userEmail == adminEmail {
return true
}
}
return false
}

View File

@@ -51,5 +51,6 @@ func RegisterAdminRoutes(baseURL string, templates *template.Template) func(r *c
// Register admin routes
r.Get("/admin", adminMiddleware.RequireAdmin(adminHandlers.HandleDashboard))
r.Get("/admin/docs/{docID}", adminMiddleware.RequireAdmin(adminHandlers.HandleDocumentDetails))
r.Get("/admin/api/chain-integrity/{docID}", adminMiddleware.RequireAdmin(adminHandlers.HandleChainIntegrityAPI))
}
}

View File

@@ -10,6 +10,7 @@ import (
"time"
"github.com/btouchard/ackify-ce/internal/domain/models"
"github.com/btouchard/ackify-ce/internal/presentation/admin"
"github.com/btouchard/ackify-ce/pkg/services"
)
@@ -53,6 +54,7 @@ type PageData struct {
TemplateName string
BaseURL string
Signatures []*models.Signature
IsAdmin bool
ServiceInfo *struct {
Name string
Icon string
@@ -275,6 +277,9 @@ func (h *SignatureHandlers) render(w http.ResponseWriter, _ *http.Request, templ
if data.TemplateName == "" {
data.TemplateName = templateName
}
if !data.IsAdmin {
data.IsAdmin = admin.IsAdminUser(data.User)
}
templateData := map[string]interface{}{
"User": data.User,
@@ -286,6 +291,7 @@ func (h *SignatureHandlers) render(w http.ResponseWriter, _ *http.Request, templ
"BaseURL": data.BaseURL,
"Signatures": data.Signatures,
"ServiceInfo": data.ServiceInfo,
"IsAdmin": data.IsAdmin,
}
if err := h.template.ExecuteTemplate(w, "base", templateData); err != nil {

View File

@@ -63,20 +63,5 @@
</div>
{{end}}
</div>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-blue-700">
<strong>Accès admin :</strong> Configuré via la variable d'environnement <code class="bg-blue-100 px-1 rounded">ACKIFY_ADMIN_EMAILS</code>
</p>
</div>
</div>
</div>
</div>
{{end}}

View File

@@ -93,6 +93,9 @@
</div>
{{if .Signatures}}
<!-- Vérification de l'intégrité de la chaîne -->
{{if .ChainIntegrity}}
{{if .ChainIntegrity.IsValid}}
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex">
<div class="flex-shrink-0">
@@ -102,11 +105,38 @@
</div>
<div class="ml-3">
<p class="text-sm text-green-700">
<strong>Document vérifié :</strong> Toutes les signatures utilisent la cryptographie Ed25519 pour garantir l'authenticité et la non-répudiation.
<strong>Chaîne de blocs intègre :</strong> {{.ChainIntegrity.ValidSigs}}/{{.ChainIntegrity.TotalSigs}} signatures valides
</p>
</div>
</div>
</div>
{{else}}
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-700">
<strong>Problème d'intégrité détecté :</strong> {{.ChainIntegrity.InvalidSigs}} signature(s) invalide(s)
</p>
{{if .ChainIntegrity.Errors}}
<div class="mt-2">
<p class="text-xs text-red-600 font-medium">Erreurs détectées :</p>
<ul class="mt-1 text-xs text-red-600 list-disc list-inside">
{{range .ChainIntegrity.Errors}}
<li>{{.}}</li>
{{end}}
</ul>
</div>
{{end}}
</div>
</div>
</div>
{{end}}
{{end}}
{{end}}
</div>
{{end}}

View File

@@ -4,9 +4,9 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Ackify - Proof of Read</title>
{{if .DocID}}
{{if and (ne .TemplateName "admin_dashboard") (ne .TemplateName "admin_doc_details")}}{{if .DocID}}
<link rel="alternate" type="application/json+oembed" href="/oembed?url={{.BaseURL}}/sign?doc={{.DocID}}&format=json" title="Signataires du document {{.DocID}}" />
{{end}}
{{end}}{{end}}
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
@@ -28,14 +28,16 @@
<header class="bg-white/80 backdrop-blur-sm border-b border-slate-200 sticky top-0 z-10">
<div class="max-w-4xl mx-auto px-6 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<a href="/" class="text-slate-400 hover:text-slate-600">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h1 class="text-xl font-bold text-slate-900">Ackify - Proof of Read</h1>
</div>
<h1 class="text-xl font-bold text-slate-900">Ackify - Proof of Read</h1>
</div>
</a>
{{if .User}}
<div class="flex items-center space-x-4">
<div class="text-sm text-slate-600">
@@ -57,7 +59,7 @@
<main class="flex-1 py-8">
<div class="max-w-4xl mx-auto px-6">
{{if eq .TemplateName "sign"}}{{template "sign" .}}{{else if eq .TemplateName "signatures"}}{{template "signatures" .}}{{else}}{{template "index" .}}{{end}}
{{if eq .TemplateName "sign"}}{{template "sign" .}}{{else if eq .TemplateName "signatures"}}{{template "signatures" .}}{{else if eq .TemplateName "admin_dashboard"}}{{template "admin_dashboard" .}}{{else if eq .TemplateName "admin_doc_details"}}{{template "admin_doc_details" .}}{{else}}{{template "index" .}}{{end}}
</div>
</main>

View File

@@ -24,12 +24,23 @@
Identifiant du document
</label>
{{if .User}}
<a href="/signatures" class="text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors flex items-center space-x-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>Mes signatures</span>
</a>
<div class="flex items-center space-x-4">
<a href="/signatures" class="text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors flex items-center space-x-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>Mes signatures</span>
</a>
{{if .IsAdmin}}
<a href="/admin" class="text-sm font-medium text-orange-600 hover:text-orange-700 transition-colors flex items-center space-x-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span>Administration</span>
</a>
{{end}}
</div>
{{end}}
</div>
<div class="relative">