mirror of
https://github.com/btouchard/ackify-ce.git
synced 2026-02-07 06:19:37 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user