feat: add PKCE support to OAuth2 flow for enhanced security

- Implement PKCE (Proof Key for Code Exchange) with S256 method
- Add crypto/pkce module with code verifier and challenge generation
- Modify OAuth flow to include code_challenge in authorization requests
- Update HandleCallback to validate code_verifier during token exchange
- Extend session lifetime from 7 to 30 days
- Add comprehensive unit tests for PKCE functions
- Maintain backward compatibility with fallback for non-PKCE sessions
- Add detailed logging for OAuth flow with PKCE tracking

PKCE enhances security by preventing authorization code interception
attacks, as recommended by OAuth 2.1 and OIDC standards.

feat: add encrypted refresh token storage with automatic cleanup

- Add oauth_sessions table for storing encrypted refresh tokens
- Implement AES-256-GCM encryption for refresh tokens using cookie secret
- Create OAuth session repository with full CRUD operations
- Add SessionWorker for automatic cleanup of expired sessions
- Configure cleanup to run every 24h for sessions older than 37 days
- Modify OAuth flow to store refresh tokens after successful authentication
- Track client IP and user agent for session security validation
- Link OAuth sessions to user sessions via session ID
- Add comprehensive encryption tests with security validations
- Integrate SessionWorker into server lifecycle with graceful shutdown

This enables persistent OAuth sessions with secure token storage,
reducing the need for frequent re-authentication from 7 to 30 days.
This commit is contained in:
Benjamin
2025-10-25 23:15:42 +02:00
parent e95185f9c7
commit 68426bc882
54 changed files with 9342 additions and 3846 deletions

View File

@@ -53,6 +53,7 @@ COPY --from=builder /app/migrate /app/migrate
COPY --from=builder /app/backend/migrations /app/migrations
COPY --from=builder /app/backend/locales /app/locales
COPY --from=builder /app/backend/templates /app/templates
COPY --from=builder /app/backend/openapi.yaml /app/openapi.yaml
ENV ACKIFY_TEMPLATES_DIR=/app/templates
ENV ACKIFY_LOCALES_DIR=/app/locales

1203
README.md

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package models
import "time"
// OAuthSession represents an OAuth session with encrypted refresh token
type OAuthSession struct {
ID int64
SessionID string
UserSub string
RefreshTokenEncrypted []byte
AccessTokenExpiresAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
LastRefreshedAt *time.Time
UserAgent string
IPAddress string
}

View File

@@ -9,17 +9,28 @@ import (
"io"
"net/http"
"strings"
"time"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
"golang.org/x/oauth2"
"github.com/btouchard/ackify-ce/backend/internal/domain/models"
"github.com/btouchard/ackify-ce/backend/pkg/crypto"
"github.com/btouchard/ackify-ce/backend/pkg/logger"
)
const sessionName = "ackapp_session"
// SessionRepository defines the interface for OAuth session storage
type SessionRepository interface {
Create(ctx context.Context, session *models.OAuthSession) error
GetBySessionID(ctx context.Context, sessionID string) (*models.OAuthSession, error)
UpdateRefreshToken(ctx context.Context, sessionID string, encryptedToken []byte, expiresAt time.Time) error
DeleteBySessionID(ctx context.Context, sessionID string) error
DeleteExpired(ctx context.Context, olderThan time.Duration) (int64, error)
}
type OauthService struct {
oauthConfig *oauth2.Config
sessionStore *sessions.CookieStore
@@ -28,6 +39,8 @@ type OauthService struct {
allowedDomain string
secureCookies bool
baseURL string
sessionRepo SessionRepository
encryptionKey []byte
}
type Config struct {
@@ -42,6 +55,7 @@ type Config struct {
AllowedDomain string
CookieSecret []byte
SecureCookies bool
SessionRepo SessionRepository
}
func NewOAuthService(config Config) *OauthService {
@@ -64,12 +78,26 @@ func NewOAuthService(config Config) *OauthService {
HttpOnly: true,
Secure: config.SecureCookies,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400 * 7, // 7 days
MaxAge: 86400 * 30, // 30 days
}
logger.Logger.Info("OAuth session store configured",
"secure_cookies", config.SecureCookies,
"max_age_days", 7)
"max_age_days", 30)
// Use CookieSecret as encryption key (must be 32 bytes for AES-256)
encryptionKey := config.CookieSecret
if len(encryptionKey) < 32 {
logger.Logger.Warn("Encryption key too short, padding to 32 bytes",
"original_length", len(encryptionKey))
// Pad with zeros (not ideal, but prevents crashes)
padded := make([]byte, 32)
copy(padded, encryptionKey)
encryptionKey = padded
} else if len(encryptionKey) > 32 {
// Truncate to 32 bytes for AES-256
encryptionKey = encryptionKey[:32]
}
return &OauthService{
oauthConfig: oauthConfig,
@@ -79,6 +107,8 @@ func NewOAuthService(config Config) *OauthService {
allowedDomain: config.AllowedDomain,
secureCookies: config.SecureCookies,
baseURL: config.BaseURL,
sessionRepo: config.SessionRepo,
encryptionKey: encryptionKey,
}
}
@@ -178,6 +208,18 @@ func (s *OauthService) GetAuthURL(nextURL string) string {
}
func (s *OauthService) CreateAuthURL(w http.ResponseWriter, r *http.Request, nextURL string) string {
// Generate PKCE code verifier and challenge
codeVerifier, err := crypto.GenerateCodeVerifier()
if err != nil {
logger.Logger.Error("Failed to generate PKCE code verifier", "error", err.Error())
// Fallback to OAuth flow without PKCE for backward compatibility
return s.createAuthURLWithoutPKCE(w, r, nextURL)
}
codeChallenge := crypto.GenerateCodeChallenge(codeVerifier)
logger.Logger.Debug("Generated PKCE parameters for OAuth flow")
// Generate state token
randPart := securecookie.GenerateRandomKey(20)
token := base64.RawURLEncoding.EncodeToString(randPart)
state := token + ":" + base64.RawURLEncoding.EncodeToString([]byte(nextURL))
@@ -188,7 +230,7 @@ func (s *OauthService) CreateAuthURL(w http.ResponseWriter, r *http.Request, nex
promptParam = "none"
}
logger.Logger.Info("Starting OAuth flow",
logger.Logger.Info("Starting OAuth flow with PKCE",
"next_url", nextURL,
"silent", isSilent,
"state_token_length", len(token))
@@ -200,10 +242,51 @@ func (s *OauthService) CreateAuthURL(w http.ResponseWriter, r *http.Request, nex
session, _ = s.sessionStore.New(r, sessionName)
}
// Store state and code_verifier in session
session.Values["oauth_state"] = token
session.Values["code_verifier"] = codeVerifier
// Session options are already configured globally on the store
// No need to set them again here
err = session.Save(r, w)
if err != nil {
logger.Logger.Error("CreateAuthURL: failed to save session", "error", err.Error())
}
// Generate OAuth URL with PKCE parameters
authURL := s.oauthConfig.AuthCodeURL(state,
oauth2.SetAuthURLParam("prompt", promptParam),
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"))
logger.Logger.Debug("CreateAuthURL: generated auth URL with PKCE",
"prompt", promptParam,
"url_length", len(authURL))
return authURL
}
// createAuthURLWithoutPKCE is a fallback method for OAuth without PKCE
// Used for backward compatibility if PKCE generation fails
func (s *OauthService) createAuthURLWithoutPKCE(w http.ResponseWriter, r *http.Request, nextURL string) string {
randPart := securecookie.GenerateRandomKey(20)
token := base64.RawURLEncoding.EncodeToString(randPart)
state := token + ":" + base64.RawURLEncoding.EncodeToString([]byte(nextURL))
promptParam := "select_account"
isSilent := r.URL.Query().Get("silent") == "true"
if isSilent {
promptParam = "none"
}
logger.Logger.Warn("Starting OAuth flow WITHOUT PKCE (fallback mode)",
"next_url", nextURL,
"silent", isSilent)
session, err := s.sessionStore.Get(r, sessionName)
if err != nil {
session, _ = s.sessionStore.New(r, sessionName)
}
session.Values["oauth_state"] = token
err = session.Save(r, w)
if err != nil {
@@ -211,9 +294,6 @@ func (s *OauthService) CreateAuthURL(w http.ResponseWriter, r *http.Request, nex
}
authURL := s.oauthConfig.AuthCodeURL(state, oauth2.SetAuthURLParam("prompt", promptParam))
logger.Logger.Debug("CreateAuthURL: generated auth URL",
"prompt", promptParam,
"url_length", len(authURL))
return authURL
}
@@ -255,7 +335,7 @@ func subtleConstantTimeCompare(a, b string) bool {
return v == 0
}
func (s *OauthService) HandleCallback(ctx context.Context, code, state string) (*models.User, string, error) {
func (s *OauthService) HandleCallback(ctx context.Context, w http.ResponseWriter, r *http.Request, code, state string) (*models.User, string, error) {
parts := strings.SplitN(state, ":", 2)
nextURL := "/"
if len(parts) == 2 {
@@ -268,14 +348,37 @@ func (s *OauthService) HandleCallback(ctx context.Context, code, state string) (
"has_code", code != "",
"next_url", nextURL)
token, err := s.oauthConfig.Exchange(ctx, code)
// Retrieve code_verifier from session for PKCE
session, _ := s.sessionStore.Get(r, sessionName)
codeVerifier, hasPKCE := session.Values["code_verifier"].(string)
// Clean up code_verifier immediately after retrieval
if hasPKCE {
delete(session.Values, "code_verifier")
_ = session.Save(r, w)
}
// Exchange authorization code for token (with or without PKCE)
var token *oauth2.Token
var err error
if hasPKCE && codeVerifier != "" {
logger.Logger.Info("OAuth token exchange with PKCE")
token, err = s.oauthConfig.Exchange(ctx, code,
oauth2.SetAuthURLParam("code_verifier", codeVerifier))
} else {
logger.Logger.Warn("OAuth token exchange without PKCE (legacy session or fallback)")
token, err = s.oauthConfig.Exchange(ctx, code)
}
if err != nil {
logger.Logger.Error("OAuth token exchange failed",
"error", err.Error())
"error", err.Error(),
"with_pkce", hasPKCE)
return nil, nextURL, fmt.Errorf("oauth exchange failed: %w", err)
}
logger.Logger.Debug("OAuth token exchange successful")
logger.Logger.Info("OAuth token exchange successful", "with_pkce", hasPKCE)
client := s.oauthConfig.Client(ctx, token)
resp, err := client.Get(s.userInfoURL)
@@ -314,9 +417,95 @@ func (s *OauthService) HandleCallback(ctx context.Context, code, state string) (
"user_email", user.Email,
"user_name", user.Name)
// Store refresh token if available and repository is configured
if token.RefreshToken != "" && s.sessionRepo != nil && s.encryptionKey != nil {
if err := s.storeRefreshToken(ctx, w, r, token, user); err != nil {
// Log error but don't fail the authentication
logger.Logger.Error("Failed to store refresh token (non-fatal)",
"user_sub", user.Sub,
"error", err.Error())
}
}
return user, nextURL, nil
}
// storeRefreshToken encrypts and stores the OAuth refresh token
func (s *OauthService) storeRefreshToken(ctx context.Context, w http.ResponseWriter, r *http.Request, token *oauth2.Token, user *models.User) error {
// Encrypt refresh token
encryptedToken, err := crypto.EncryptToken(token.RefreshToken, s.encryptionKey)
if err != nil {
return fmt.Errorf("failed to encrypt refresh token: %w", err)
}
// Generate unique session ID for OAuth session tracking
sessionID := generateSessionID()
// Get client IP and user agent for security tracking
ipAddress := getClientIP(r)
userAgent := r.UserAgent()
// Create OAuth session
oauthSession := &models.OAuthSession{
SessionID: sessionID,
UserSub: user.Sub,
RefreshTokenEncrypted: encryptedToken,
AccessTokenExpiresAt: token.Expiry,
UserAgent: userAgent,
IPAddress: ipAddress,
}
// Save to database
if err := s.sessionRepo.Create(ctx, oauthSession); err != nil {
return fmt.Errorf("failed to create OAuth session: %w", err)
}
// Link OAuth session ID to user session
userSession, _ := s.sessionStore.Get(r, sessionName)
userSession.Values["oauth_session_id"] = sessionID
if err := userSession.Save(r, w); err != nil {
logger.Logger.Error("Failed to link OAuth session to user session",
"session_id", sessionID,
"error", err.Error())
// Don't return error, session is already created in DB
}
logger.Logger.Info("Stored encrypted refresh token",
"user_sub", user.Sub,
"session_id", sessionID,
"expires_at", token.Expiry)
return nil
}
// generateSessionID generates a unique session ID for OAuth sessions
func generateSessionID() string {
nonce, _ := crypto.GenerateNonce()
return nonce
}
// getClientIP extracts the client IP address from the request
func getClientIP(r *http.Request) string {
// Check X-Forwarded-For header (if behind proxy)
forwarded := r.Header.Get("X-Forwarded-For")
if forwarded != "" {
// Take the first IP in the list
parts := strings.Split(forwarded, ",")
if len(parts) > 0 {
return strings.TrimSpace(parts[0])
}
}
// Check X-Real-IP header
realIP := r.Header.Get("X-Real-IP")
if realIP != "" {
return realIP
}
// Fallback to RemoteAddr
return r.RemoteAddr
}
func (s *OauthService) IsAllowedDomain(email string) bool {
if s.allowedDomain == "" {
return true

View File

@@ -754,7 +754,9 @@ func TestOauthService_HandleCallback_StateDecoding(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// We can't easily test the full HandleCallback without mocking OAuth2 exchange
// So we test the state parsing logic by calling with invalid code
_, nextURL, _ := service.HandleCallback(context.Background(), "invalid-code", tt.state)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
_, nextURL, _ := service.HandleCallback(context.Background(), w, r, "invalid-code", tt.state)
if nextURL != tt.expectedURL {
t.Errorf("NextURL = %v, expected %v", nextURL, tt.expectedURL)

View File

@@ -0,0 +1,165 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package auth
import (
"context"
"fmt"
"sync"
"time"
"github.com/btouchard/ackify-ce/backend/pkg/logger"
)
// SessionWorker handles background cleanup of expired OAuth sessions
type SessionWorker struct {
sessionRepo SessionRepository
cleanupInterval time.Duration
cleanupAge time.Duration
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
stopChan chan struct{}
started bool
mu sync.Mutex
}
// SessionWorkerConfig contains configuration for the session worker
type SessionWorkerConfig struct {
CleanupInterval time.Duration // How often to run cleanup (default: 24 hours)
CleanupAge time.Duration // Age of sessions to delete (default: 37 days = 30 + 7 grace period)
}
// DefaultSessionWorkerConfig returns default session worker configuration
func DefaultSessionWorkerConfig() SessionWorkerConfig {
return SessionWorkerConfig{
CleanupInterval: 24 * time.Hour, // Run cleanup once per day
CleanupAge: (30 + 7) * 24 * time.Hour, // Delete sessions older than 37 days
}
}
// NewSessionWorker creates a new OAuth session cleanup worker
func NewSessionWorker(sessionRepo SessionRepository, config SessionWorkerConfig) *SessionWorker {
// Apply defaults
if config.CleanupInterval <= 0 {
config.CleanupInterval = 24 * time.Hour
}
if config.CleanupAge <= 0 {
config.CleanupAge = 37 * 24 * time.Hour
}
ctx, cancel := context.WithCancel(context.Background())
return &SessionWorker{
sessionRepo: sessionRepo,
cleanupInterval: config.CleanupInterval,
cleanupAge: config.CleanupAge,
ctx: ctx,
cancel: cancel,
stopChan: make(chan struct{}),
}
}
// Start begins the cleanup worker
func (w *SessionWorker) Start() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.started {
return fmt.Errorf("session worker already started")
}
logger.Logger.Info("Starting OAuth session cleanup worker",
"cleanup_interval", w.cleanupInterval,
"cleanup_age", w.cleanupAge)
w.started = true
// Start the cleanup loop
w.wg.Add(1)
go w.cleanupLoop()
return nil
}
// Stop gracefully stops the worker
func (w *SessionWorker) Stop() error {
w.mu.Lock()
if !w.started {
w.mu.Unlock()
return fmt.Errorf("session worker not started")
}
w.mu.Unlock()
logger.Logger.Info("Stopping OAuth session cleanup worker...")
// Signal shutdown
w.cancel()
close(w.stopChan)
// Wait for goroutines to finish with timeout
done := make(chan struct{})
go func() {
w.wg.Wait()
close(done)
}()
select {
case <-done:
logger.Logger.Info("OAuth session cleanup worker stopped gracefully")
case <-time.After(30 * time.Second):
logger.Logger.Warn("OAuth session cleanup worker stop timeout")
}
w.mu.Lock()
w.started = false
w.mu.Unlock()
return nil
}
// cleanupLoop periodically cleans up expired sessions
func (w *SessionWorker) cleanupLoop() {
defer w.wg.Done()
ticker := time.NewTicker(w.cleanupInterval)
defer ticker.Stop()
// Run cleanup immediately on start
w.performCleanup()
for {
select {
case <-w.ctx.Done():
return
case <-w.stopChan:
return
case <-ticker.C:
w.performCleanup()
}
}
}
// performCleanup removes expired OAuth sessions
func (w *SessionWorker) performCleanup() {
ctx, cancel := context.WithTimeout(w.ctx, 5*time.Minute)
defer cancel()
logger.Logger.Debug("Starting OAuth session cleanup",
"older_than", w.cleanupAge)
deleted, err := w.sessionRepo.DeleteExpired(ctx, w.cleanupAge)
if err != nil {
logger.Logger.Error("Failed to cleanup expired OAuth sessions",
"error", err.Error())
return
}
if deleted > 0 {
logger.Logger.Info("Cleaned up expired OAuth sessions",
"count", deleted,
"older_than", w.cleanupAge)
} else {
logger.Logger.Debug("No expired OAuth sessions to clean up")
}
}

View File

@@ -0,0 +1,212 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package database
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/btouchard/ackify-ce/backend/internal/domain/models"
"github.com/btouchard/ackify-ce/backend/pkg/logger"
)
// oauthSessionRepository defines the interface for OAuth session operations
type oauthSessionRepository interface {
Create(ctx context.Context, session *models.OAuthSession) error
GetBySessionID(ctx context.Context, sessionID string) (*models.OAuthSession, error)
UpdateRefreshToken(ctx context.Context, sessionID string, encryptedToken []byte, expiresAt time.Time) error
DeleteBySessionID(ctx context.Context, sessionID string) error
DeleteExpired(ctx context.Context, olderThan time.Duration) (int64, error)
}
// OAuthSessionRepository implements the OAuth session repository
type OAuthSessionRepository struct {
db *sql.DB
}
// NewOAuthSessionRepository creates a new OAuth session repository
func NewOAuthSessionRepository(db *sql.DB) *OAuthSessionRepository {
return &OAuthSessionRepository{db: db}
}
// Create creates a new OAuth session
func (r *OAuthSessionRepository) Create(ctx context.Context, session *models.OAuthSession) error {
query := `
INSERT INTO oauth_sessions (
session_id,
user_sub,
refresh_token_encrypted,
access_token_expires_at,
user_agent,
ip_address
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at, updated_at
`
err := r.db.QueryRowContext(
ctx,
query,
session.SessionID,
session.UserSub,
session.RefreshTokenEncrypted,
session.AccessTokenExpiresAt,
session.UserAgent,
session.IPAddress,
).Scan(&session.ID, &session.CreatedAt, &session.UpdatedAt)
if err != nil {
logger.Logger.Error("Failed to create OAuth session",
"session_id", session.SessionID,
"user_sub", session.UserSub,
"error", err.Error())
return fmt.Errorf("failed to create OAuth session: %w", err)
}
logger.Logger.Info("Created OAuth session",
"session_id", session.SessionID,
"user_sub", session.UserSub)
return nil
}
// GetBySessionID retrieves an OAuth session by session ID
func (r *OAuthSessionRepository) GetBySessionID(ctx context.Context, sessionID string) (*models.OAuthSession, error) {
query := `
SELECT
id,
session_id,
user_sub,
refresh_token_encrypted,
access_token_expires_at,
created_at,
updated_at,
last_refreshed_at,
user_agent,
ip_address
FROM oauth_sessions
WHERE session_id = $1
`
session := &models.OAuthSession{}
var lastRefreshedAt sql.NullTime
err := r.db.QueryRowContext(ctx, query, sessionID).Scan(
&session.ID,
&session.SessionID,
&session.UserSub,
&session.RefreshTokenEncrypted,
&session.AccessTokenExpiresAt,
&session.CreatedAt,
&session.UpdatedAt,
&lastRefreshedAt,
&session.UserAgent,
&session.IPAddress,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("OAuth session not found")
}
if err != nil {
logger.Logger.Error("Failed to get OAuth session",
"session_id", sessionID,
"error", err.Error())
return nil, fmt.Errorf("failed to get OAuth session: %w", err)
}
if lastRefreshedAt.Valid {
session.LastRefreshedAt = &lastRefreshedAt.Time
}
return session, nil
}
// UpdateRefreshToken updates the refresh token and expiration time
func (r *OAuthSessionRepository) UpdateRefreshToken(ctx context.Context, sessionID string, encryptedToken []byte, expiresAt time.Time) error {
query := `
UPDATE oauth_sessions
SET
refresh_token_encrypted = $1,
access_token_expires_at = $2,
last_refreshed_at = now(),
updated_at = now()
WHERE session_id = $3
`
result, err := r.db.ExecContext(ctx, query, encryptedToken, expiresAt, sessionID)
if err != nil {
logger.Logger.Error("Failed to update OAuth session refresh token",
"session_id", sessionID,
"error", err.Error())
return fmt.Errorf("failed to update refresh token: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("OAuth session not found")
}
logger.Logger.Info("Updated OAuth session refresh token",
"session_id", sessionID)
return nil
}
// DeleteBySessionID deletes an OAuth session by session ID
func (r *OAuthSessionRepository) DeleteBySessionID(ctx context.Context, sessionID string) error {
query := `DELETE FROM oauth_sessions WHERE session_id = $1`
result, err := r.db.ExecContext(ctx, query, sessionID)
if err != nil {
logger.Logger.Error("Failed to delete OAuth session",
"session_id", sessionID,
"error", err.Error())
return fmt.Errorf("failed to delete OAuth session: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected > 0 {
logger.Logger.Info("Deleted OAuth session", "session_id", sessionID)
}
return nil
}
// DeleteExpired deletes OAuth sessions older than the specified duration
func (r *OAuthSessionRepository) DeleteExpired(ctx context.Context, olderThan time.Duration) (int64, error) {
query := `
DELETE FROM oauth_sessions
WHERE updated_at < $1
`
cutoffTime := time.Now().Add(-olderThan)
result, err := r.db.ExecContext(ctx, query, cutoffTime)
if err != nil {
logger.Logger.Error("Failed to delete expired OAuth sessions",
"cutoff_time", cutoffTime,
"error", err.Error())
return 0, fmt.Errorf("failed to delete expired sessions: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected > 0 {
logger.Logger.Info("Deleted expired OAuth sessions",
"count", rowsAffected,
"older_than", olderThan)
}
return rowsAffected, nil
}

View File

@@ -41,7 +41,7 @@ func TestNewI18n_InvalidDirectory(t *testing.T) {
assert.Error(t, err)
assert.Nil(t, i18n)
assert.Contains(t, err.Error(), "failed to load English translations")
assert.Contains(t, err.Error(), "failed to load en translations")
}
func TestNewI18n_MissingEnglishFile(t *testing.T) {
@@ -81,10 +81,10 @@ func TestI18n_T_EnglishTranslation(t *testing.T) {
require.NoError(t, err)
// Test a known key from en.json
result := i18n.T("en", "site.title")
result := i18n.T("en", "email.reminder.subject")
assert.NotEmpty(t, result)
assert.NotEqual(t, "site.title", result, "Should return translation, not key")
assert.Contains(t, result, "Ackify", "Should contain 'Ackify'")
assert.NotEqual(t, "email.reminder.subject", result, "Should return translation, not key")
assert.Contains(t, result, "Document Reading", "Should contain expected text")
}
func TestI18n_T_FrenchTranslation(t *testing.T) {
@@ -94,10 +94,10 @@ func TestI18n_T_FrenchTranslation(t *testing.T) {
require.NoError(t, err)
// Test a known key from fr.json
result := i18n.T("fr", "site.title")
result := i18n.T("fr", "email.reminder.subject")
assert.NotEmpty(t, result)
assert.NotEqual(t, "site.title", result, "Should return translation, not key")
assert.Contains(t, result, "Ackify", "Should contain 'Ackify'")
assert.NotEqual(t, "email.reminder.subject", result, "Should return translation, not key")
assert.Contains(t, result, "lecture", "Should contain expected French text")
}
func TestI18n_T_FallbackToEnglish(t *testing.T) {
@@ -107,7 +107,7 @@ func TestI18n_T_FallbackToEnglish(t *testing.T) {
require.NoError(t, err)
// Request French translation for a key - should work for existing keys
result := i18n.T("fr", "site.title")
result := i18n.T("fr", "email.reminder.subject")
assert.NotEmpty(t, result)
}
@@ -129,10 +129,10 @@ func TestI18n_T_UnknownLanguage(t *testing.T) {
i18n, err := NewI18n(testLocalesDir)
require.NoError(t, err)
// Test with unsupported language, should fallback to English
result := i18n.T("de", "site.title")
// Test with unsupported language (Chinese), should fallback to English
result := i18n.T("zh", "email.reminder.subject")
assert.NotEmpty(t, result)
assert.Contains(t, result, "Ackify", "Should fallback to English translation")
assert.Contains(t, result, "Document Reading", "Should fallback to English translation")
}
// ============================================================================
@@ -215,7 +215,7 @@ func TestGetLangFromRequest_FromAcceptLanguageHeader(t *testing.T) {
},
{
name: "Unsupported language defaults to English",
acceptLang: "de,es",
acceptLang: "zh,ja",
expectedLang: "en",
},
}
@@ -327,7 +327,7 @@ func TestSetLangCookie_UnsupportedLanguage(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
SetLangCookie(rec, "de", false)
SetLangCookie(rec, "zh", false)
cookies := rec.Result().Cookies()
require.Len(t, cookies, 1)
@@ -379,14 +379,14 @@ func Test_normalizeLang(t *testing.T) {
expected: "en",
},
{
name: "Other language",
name: "German",
input: "de",
expected: "de",
},
{
name: "Other language with region",
name: "German with region",
input: "de-DE",
expected: "de-de",
expected: "de",
},
}
@@ -435,11 +435,21 @@ func Test_isSupported(t *testing.T) {
{
name: "German",
lang: "de",
expected: false,
expected: true,
},
{
name: "Spanish",
lang: "es",
expected: true,
},
{
name: "Italian",
lang: "it",
expected: true,
},
{
name: "Unsupported language (Chinese)",
lang: "zh",
expected: false,
},
}
@@ -484,8 +494,8 @@ func TestI18n_GetTranslations_UnsupportedLanguage(t *testing.T) {
i18n, err := NewI18n(testLocalesDir)
require.NoError(t, err)
// Should fallback to default language (English)
translations := i18n.GetTranslations("de")
// Should fallback to default language (English) for truly unsupported languages
translations := i18n.GetTranslations("zh")
assert.NotEmpty(t, translations)
assert.Equal(t, i18n.translations[DefaultLang], translations)
}
@@ -512,7 +522,7 @@ func TestI18n_T_Concurrent(t *testing.T) {
lang = "fr"
}
result := i18n.T(lang, "site.title")
result := i18n.T(lang, "email.reminder.subject")
assert.NotEmpty(t, result)
}(i)
}
@@ -533,7 +543,7 @@ func BenchmarkI18n_T(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
i18n.T("en", "site.title")
i18n.T("en", "email.reminder.subject")
}
}
@@ -543,7 +553,7 @@ func BenchmarkI18n_T_Parallel(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
i18n.T("en", "site.title")
i18n.T("en", "email.reminder.subject")
}
})
}

View File

@@ -656,7 +656,7 @@ func TestHandleSendReminders_Success(t *testing.T) {
sendRemindersFunc: func(ctx context.Context, docID, sentBy string, specificEmails []string, docURL string, locale string) (*models.ReminderSendResult, error) {
assert.Equal(t, "doc1", docID)
assert.Equal(t, "admin@example.com", sentBy)
assert.Equal(t, "fr", locale)
assert.Equal(t, "en", locale) // Default locale when no language preference is set
return &models.ReminderSendResult{
TotalAttempted: 2,
SuccessfullySent: 2,

View File

@@ -146,7 +146,7 @@ func (h *Handler) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {
}
ctx := r.Context()
user, nextURL, err := h.authService.HandleCallback(ctx, code, state)
user, nextURL, err := h.authService.HandleCallback(ctx, w, r, code, state)
if err != nil {
logger.Logger.Error("OAuth callback failed", "error", err.Error())
handlers.HandleError(w, err)

View File

@@ -2,11 +2,14 @@
package api
import (
"encoding/json"
"net/http"
"os"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"gopkg.in/yaml.v3"
"github.com/btouchard/ackify-ce/backend/internal/application/services"
"github.com/btouchard/ackify-ce/backend/internal/infrastructure/auth"
@@ -167,9 +170,34 @@ func NewRouter(cfg RouterConfig) *chi.Mux {
// serveOpenAPISpec serves the OpenAPI specification
func serveOpenAPISpec(w http.ResponseWriter, r *http.Request) {
// TODO: Read and serve the OpenAPI YAML file as JSON
// For now, return a simple response
// Read the OpenAPI YAML file and convert to JSON
yamlData, err := os.ReadFile("openapi.yaml")
if err != nil {
// Fallback to basic response if file not found
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"info":{"title":"Ackify API","version":"1.0.0"},"message":"OpenAPI spec file not found - see /backend/openapi.yaml"}`))
return
}
// Parse YAML and convert to JSON
var spec map[string]interface{}
if err := yaml.Unmarshal(yamlData, &spec); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error":"Failed to parse OpenAPI spec"}`))
return
}
jsonData, err := json.MarshalIndent(spec, "", " ")
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error":"Failed to convert OpenAPI spec to JSON"}`))
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"info":{"title":"Ackify API","version":"1.0.0"}}`))
w.Write(jsonData)
}

View File

@@ -0,0 +1,4 @@
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Drop oauth_sessions table
DROP TABLE IF EXISTS oauth_sessions CASCADE;

View File

@@ -0,0 +1,29 @@
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Table for storing OAuth refresh tokens securely
CREATE TABLE IF NOT EXISTS oauth_sessions (
id BIGSERIAL PRIMARY KEY,
session_id TEXT NOT NULL UNIQUE, -- Gorilla session ID
user_sub TEXT NOT NULL, -- OAuth user ID (sub claim)
refresh_token_encrypted BYTEA NOT NULL, -- AES-256-GCM encrypted refresh token
access_token_expires_at TIMESTAMPTZ, -- When the access token expires
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_refreshed_at TIMESTAMPTZ, -- Last time token was refreshed
-- Security metadata for session validation
user_agent TEXT, -- User agent for session binding
ip_address INET -- IP address for session binding
);
-- Indexes for fast lookups
CREATE INDEX IF NOT EXISTS idx_oauth_sessions_session_id ON oauth_sessions(session_id);
CREATE INDEX IF NOT EXISTS idx_oauth_sessions_user_sub ON oauth_sessions(user_sub);
CREATE INDEX IF NOT EXISTS idx_oauth_sessions_expires_at ON oauth_sessions(access_token_expires_at);
-- Comment for documentation
COMMENT ON TABLE oauth_sessions IS 'Stores encrypted OAuth refresh tokens for session management';
COMMENT ON COLUMN oauth_sessions.refresh_token_encrypted IS 'Refresh token encrypted with AES-256-GCM';
COMMENT ON COLUMN oauth_sessions.session_id IS 'Links to the gorilla session cookie';
COMMENT ON COLUMN oauth_sessions.user_agent IS 'Used to detect session hijacking';
COMMENT ON COLUMN oauth_sessions.ip_address IS 'Used to detect session hijacking';

936
backend/openapi.yaml Normal file
View File

@@ -0,0 +1,936 @@
openapi: 3.0.3
info:
title: Ackify API
description: |
REST API for Ackify - Document signature tracking with cryptographic Ed25519 signatures.
## Authentication
Most endpoints require OAuth2 authentication via session cookies.
Admin endpoints additionally require the user's email to be in the ACKIFY_ADMIN_EMAILS list.
## CSRF Protection
Write operations (POST, PUT, DELETE) require a CSRF token obtained from `GET /api/v1/csrf`.
Include the token in the `X-CSRF-Token` header.
version: 1.0.0
contact:
name: Ackify Support
url: https://github.com/btouchard/ackify-ce
license:
name: AGPL-3.0-or-later
url: https://www.gnu.org/licenses/agpl-3.0.html
servers:
- url: /api/v1
description: API v1
tags:
- name: Health
description: System health checks
- name: Auth
description: OAuth2 authentication endpoints
- name: Users
description: User information
- name: Documents
description: Document management (public)
- name: Signatures
description: Signature creation and retrieval
- name: Admin - Documents
description: Admin document management
- name: Admin - Signers
description: Admin expected signers management
- name: Admin - Reminders
description: Admin email reminder management
paths:
/health:
get:
summary: Health check
description: Returns the health status of the API
tags:
- Health
responses:
'200':
description: Service is healthy
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: ok
timestamp:
type: string
format: date-time
/csrf:
get:
summary: Get CSRF token
description: Returns a CSRF token required for write operations
tags:
- Auth
responses:
'200':
description: CSRF token generated
content:
application/json:
schema:
type: object
properties:
csrfToken:
type: string
example: abc123def456
/auth/start:
post:
summary: Start OAuth2 flow
description: Initiates OAuth2 authentication with the configured provider
tags:
- Auth
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
redirectTo:
type: string
description: URL to redirect to after successful authentication
example: /signatures
responses:
'302':
description: Redirect to OAuth provider
'400':
description: Invalid request
/auth/callback:
get:
summary: OAuth2 callback
description: Handles OAuth2 provider callback after user authentication
tags:
- Auth
parameters:
- name: code
in: query
required: true
schema:
type: string
- name: state
in: query
required: true
schema:
type: string
responses:
'302':
description: Redirect to application
'400':
description: Invalid callback parameters
'401':
description: Authentication failed
/auth/logout:
get:
summary: Logout
description: Logs out the current user and clears the session
tags:
- Auth
responses:
'302':
description: Redirect to home page
/auth/check:
get:
summary: Check authentication status
description: Checks if user has an active OAuth session (only available if ACKIFY_OAUTH_AUTO_LOGIN=true)
tags:
- Auth
responses:
'200':
description: Authentication status
content:
application/json:
schema:
type: object
properties:
authenticated:
type: boolean
/users/me:
get:
summary: Get current user
description: Returns information about the currently authenticated user
tags:
- Users
security:
- sessionAuth: []
responses:
'200':
description: Current user information
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'401':
description: Not authenticated
/documents:
get:
summary: List documents
description: Returns a paginated list of all documents
tags:
- Documents
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: limit
in: query
schema:
type: integer
default: 20
maximum: 100
responses:
'200':
description: List of documents
content:
application/json:
schema:
type: object
properties:
documents:
type: array
items:
$ref: '#/components/schemas/Document'
total:
type: integer
page:
type: integer
limit:
type: integer
post:
summary: Create document
description: Creates a new document with metadata
tags:
- Documents
security:
- csrfToken: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateDocumentRequest'
responses:
'201':
description: Document created
content:
application/json:
schema:
$ref: '#/components/schemas/Document'
'400':
description: Invalid request
'403':
description: CSRF token missing or invalid
/documents/{docId}:
get:
summary: Get document
description: Returns document metadata and signature count
tags:
- Documents
parameters:
- name: docId
in: path
required: true
schema:
type: string
responses:
'200':
description: Document details
content:
application/json:
schema:
$ref: '#/components/schemas/DocumentWithCount'
'404':
description: Document not found
/documents/{docId}/signatures:
get:
summary: Get document signatures
description: Returns all signatures for a document
tags:
- Documents
parameters:
- name: docId
in: path
required: true
schema:
type: string
responses:
'200':
description: List of signatures
content:
application/json:
schema:
type: object
properties:
signatures:
type: array
items:
$ref: '#/components/schemas/Signature'
/documents/{docId}/signatures/status:
get:
summary: Get user signature status
description: Checks if the current user has signed this document
tags:
- Signatures
security:
- sessionAuth: []
parameters:
- name: docId
in: path
required: true
schema:
type: string
responses:
'200':
description: Signature status
content:
application/json:
schema:
type: object
properties:
hasSigned:
type: boolean
signature:
$ref: '#/components/schemas/Signature'
/documents/{docId}/expected-signers:
get:
summary: Get expected signers
description: Returns the list of expected signers for a document
tags:
- Documents
parameters:
- name: docId
in: path
required: true
schema:
type: string
responses:
'200':
description: List of expected signers
content:
application/json:
schema:
type: object
properties:
expectedSigners:
type: array
items:
$ref: '#/components/schemas/ExpectedSigner'
/documents/find-or-create:
get:
summary: Find or create document
description: Finds a document by reference, or creates it if it doesn't exist
tags:
- Documents
parameters:
- name: ref
in: query
required: true
schema:
type: string
description: Document reference (URL, path, or custom ID)
responses:
'200':
description: Document found or created
content:
application/json:
schema:
$ref: '#/components/schemas/Document'
/signatures:
get:
summary: Get user signatures
description: Returns all signatures created by the current user
tags:
- Signatures
security:
- sessionAuth: []
responses:
'200':
description: List of user signatures
content:
application/json:
schema:
type: object
properties:
signatures:
type: array
items:
$ref: '#/components/schemas/Signature'
post:
summary: Create signature
description: Creates a cryptographic Ed25519 signature for a document
tags:
- Signatures
security:
- sessionAuth: []
- csrfToken: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateSignatureRequest'
responses:
'201':
description: Signature created
content:
application/json:
schema:
$ref: '#/components/schemas/Signature'
'400':
description: Invalid request
'409':
description: User has already signed this document
/admin/documents:
get:
summary: List all documents (admin)
description: Returns paginated list of all documents with admin metadata
tags:
- Admin - Documents
security:
- sessionAuth: []
- adminRole: []
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: limit
in: query
schema:
type: integer
default: 20
responses:
'200':
description: List of documents
content:
application/json:
schema:
type: object
properties:
documents:
type: array
items:
$ref: '#/components/schemas/Document'
total:
type: integer
/admin/documents/{docId}:
get:
summary: Get document details (admin)
description: Returns detailed document information
tags:
- Admin - Documents
security:
- sessionAuth: []
- adminRole: []
parameters:
- name: docId
in: path
required: true
schema:
type: string
responses:
'200':
description: Document details
content:
application/json:
schema:
$ref: '#/components/schemas/Document'
delete:
summary: Delete document (admin)
description: Soft deletes a document
tags:
- Admin - Documents
security:
- sessionAuth: []
- adminRole: []
- csrfToken: []
parameters:
- name: docId
in: path
required: true
schema:
type: string
responses:
'204':
description: Document deleted
'404':
description: Document not found
/admin/documents/{docId}/metadata:
put:
summary: Update document metadata (admin)
description: Updates document title, URL, checksum, and description
tags:
- Admin - Documents
security:
- sessionAuth: []
- adminRole: []
- csrfToken: []
parameters:
- name: docId
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateDocumentMetadataRequest'
responses:
'200':
description: Metadata updated
content:
application/json:
schema:
$ref: '#/components/schemas/Document'
/admin/documents/{docId}/signers:
get:
summary: Get document with signers (admin)
description: Returns document with both expected and actual signers
tags:
- Admin - Signers
security:
- sessionAuth: []
- adminRole: []
parameters:
- name: docId
in: path
required: true
schema:
type: string
responses:
'200':
description: Document with signers
content:
application/json:
schema:
$ref: '#/components/schemas/DocumentWithSigners'
post:
summary: Add expected signer (admin)
description: Adds one or more expected signers to a document
tags:
- Admin - Signers
security:
- sessionAuth: []
- adminRole: []
- csrfToken: []
parameters:
- name: docId
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AddExpectedSignerRequest'
responses:
'201':
description: Signer(s) added
'400':
description: Invalid request
/admin/documents/{docId}/signers/{email}:
delete:
summary: Remove expected signer (admin)
description: Removes an expected signer from a document
tags:
- Admin - Signers
security:
- sessionAuth: []
- adminRole: []
- csrfToken: []
parameters:
- name: docId
in: path
required: true
schema:
type: string
- name: email
in: path
required: true
schema:
type: string
responses:
'204':
description: Signer removed
/admin/documents/{docId}/status:
get:
summary: Get document status (admin)
description: Returns completion statistics for a document
tags:
- Admin - Documents
security:
- sessionAuth: []
- adminRole: []
parameters:
- name: docId
in: path
required: true
schema:
type: string
responses:
'200':
description: Document status
content:
application/json:
schema:
$ref: '#/components/schemas/DocumentStatus'
/admin/documents/{docId}/reminders:
get:
summary: Get reminder history (admin)
description: Returns email reminder send history for a document
tags:
- Admin - Reminders
security:
- sessionAuth: []
- adminRole: []
parameters:
- name: docId
in: path
required: true
schema:
type: string
responses:
'200':
description: Reminder history
content:
application/json:
schema:
type: object
properties:
reminders:
type: array
items:
$ref: '#/components/schemas/ReminderLog'
post:
summary: Send reminders (admin)
description: Sends email reminders to pending signers
tags:
- Admin - Reminders
security:
- sessionAuth: []
- adminRole: []
- csrfToken: []
parameters:
- name: docId
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SendRemindersRequest'
responses:
'200':
description: Reminders queued
content:
application/json:
schema:
type: object
properties:
queued:
type: integer
description: Number of emails queued for sending
/openapi.json:
get:
summary: Get OpenAPI specification
description: Returns this OpenAPI specification in JSON format
tags:
- Health
responses:
'200':
description: OpenAPI spec
content:
application/json:
schema:
type: object
components:
securitySchemes:
sessionAuth:
type: apiKey
in: cookie
name: session
description: OAuth2 session cookie
csrfToken:
type: apiKey
in: header
name: X-CSRF-Token
description: CSRF protection token
adminRole:
type: http
scheme: bearer
description: Admin email must be in ACKIFY_ADMIN_EMAILS
schemas:
User:
type: object
properties:
id:
type: string
description: User unique identifier (OAuth sub claim)
email:
type: string
format: email
name:
type: string
isAdmin:
type: boolean
Document:
type: object
properties:
docId:
type: string
example: abc123
title:
type: string
url:
type: string
checksum:
type: string
checksumAlgorithm:
type: string
enum: [SHA-256, SHA-512, MD5]
description:
type: string
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
createdBy:
type: string
DocumentWithCount:
allOf:
- $ref: '#/components/schemas/Document'
- type: object
properties:
signatureCount:
type: integer
Signature:
type: object
properties:
id:
type: integer
format: int64
docId:
type: string
userSub:
type: string
userEmail:
type: string
format: email
userName:
type: string
signedAt:
type: string
format: date-time
payloadHash:
type: string
signature:
type: string
description: Ed25519 signature (hex-encoded)
nonce:
type: string
createdAt:
type: string
format: date-time
referer:
type: string
prevHash:
type: string
docChecksum:
type: string
ExpectedSigner:
type: object
properties:
id:
type: integer
format: int64
docId:
type: string
email:
type: string
format: email
name:
type: string
addedAt:
type: string
format: date-time
addedBy:
type: string
notes:
type: string
ReminderLog:
type: object
properties:
id:
type: integer
format: int64
docId:
type: string
recipientEmail:
type: string
format: email
sentAt:
type: string
format: date-time
sentBy:
type: string
templateUsed:
type: string
status:
type: string
enum: [sent, failed, bounced, queued]
errorMessage:
type: string
DocumentStatus:
type: object
properties:
docId:
type: string
expectedCount:
type: integer
signedCount:
type: integer
pendingCount:
type: integer
completionPercentage:
type: number
format: float
DocumentWithSigners:
allOf:
- $ref: '#/components/schemas/Document'
- type: object
properties:
expectedSigners:
type: array
items:
$ref: '#/components/schemas/ExpectedSigner'
signatures:
type: array
items:
$ref: '#/components/schemas/Signature'
CreateDocumentRequest:
type: object
required:
- reference
properties:
reference:
type: string
description: Document URL, path, or custom ID
title:
type: string
description:
type: string
UpdateDocumentMetadataRequest:
type: object
properties:
title:
type: string
url:
type: string
checksum:
type: string
checksumAlgorithm:
type: string
enum: [SHA-256, SHA-512, MD5]
description:
type: string
CreateSignatureRequest:
type: object
required:
- docId
properties:
docId:
type: string
referer:
type: string
description: Source service (Google Docs, GitHub, etc.)
docChecksum:
type: string
description: Document checksum at signing time
AddExpectedSignerRequest:
type: object
required:
- emails
properties:
emails:
type: array
items:
type: string
format: email
notes:
type: string
SendRemindersRequest:
type: object
properties:
emails:
type: array
items:
type: string
format: email
description: Specific emails to send to (omit to send to all pending)
docURL:
type: string
description: Custom document URL for email
locale:
type: string
enum: [en, fr, es, de, it]
default: en
description: Email language

View File

@@ -0,0 +1,90 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"
"io"
)
// EncryptToken encrypts a plaintext token using AES-256-GCM
// The key must be 32 bytes for AES-256
// Returns: nonce + ciphertext + auth tag (combined)
func EncryptToken(plaintext string, key []byte) ([]byte, error) {
if len(key) != 32 {
return nil, fmt.Errorf("encryption key must be 32 bytes for AES-256, got %d bytes", len(key))
}
if plaintext == "" {
return nil, fmt.Errorf("cannot encrypt empty plaintext")
}
// Create AES cipher block
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
// Create GCM mode (provides both confidentiality and authenticity)
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
// Generate a random nonce (must be unique for each encryption with the same key)
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("failed to generate nonce: %w", err)
}
// Encrypt and authenticate the plaintext
// Seal appends the ciphertext and tag to the nonce
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return ciphertext, nil
}
// DecryptToken decrypts a ciphertext using AES-256-GCM
// The key must be 32 bytes for AES-256
// Expects input format: nonce + ciphertext + auth tag (as created by EncryptToken)
func DecryptToken(ciphertext []byte, key []byte) (string, error) {
if len(key) != 32 {
return "", fmt.Errorf("decryption key must be 32 bytes for AES-256, got %d bytes", len(key))
}
if len(ciphertext) == 0 {
return "", fmt.Errorf("cannot decrypt empty ciphertext")
}
// Create AES cipher block
block, err := aes.NewCipher(key)
if err != nil {
return "", fmt.Errorf("failed to create cipher: %w", err)
}
// Create GCM mode
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("failed to create GCM: %w", err)
}
// Check minimum length (nonce + at least 1 byte of data + tag)
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return "", fmt.Errorf("ciphertext too short: expected at least %d bytes, got %d", nonceSize, len(ciphertext))
}
// Extract nonce and ciphertext+tag
nonce := ciphertext[:nonceSize]
encryptedData := ciphertext[nonceSize:]
// Decrypt and verify authenticity
plaintext, err := gcm.Open(nil, nonce, encryptedData, nil)
if err != nil {
return "", fmt.Errorf("failed to decrypt: %w", err)
}
return string(plaintext), nil
}

View File

@@ -0,0 +1,257 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package crypto
import (
"crypto/rand"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEncryptDecrypt(t *testing.T) {
key := make([]byte, 32) // AES-256 requires 32 bytes
_, err := rand.Read(key)
require.NoError(t, err)
t.Run("encrypt and decrypt successfully", func(t *testing.T) {
plaintext := "my-secret-refresh-token-12345"
ciphertext, err := EncryptToken(plaintext, key)
require.NoError(t, err)
assert.NotEmpty(t, ciphertext)
// Ciphertext should be different from plaintext
assert.NotEqual(t, plaintext, string(ciphertext))
// Decrypt
decrypted, err := DecryptToken(ciphertext, key)
require.NoError(t, err)
assert.Equal(t, plaintext, decrypted)
})
t.Run("encrypt produces different ciphertext each time", func(t *testing.T) {
plaintext := "same-plaintext"
ciphertext1, err := EncryptToken(plaintext, key)
require.NoError(t, err)
ciphertext2, err := EncryptToken(plaintext, key)
require.NoError(t, err)
// Different nonces should produce different ciphertexts
assert.NotEqual(t, ciphertext1, ciphertext2)
// Both should decrypt to the same plaintext
decrypted1, err := DecryptToken(ciphertext1, key)
require.NoError(t, err)
decrypted2, err := DecryptToken(ciphertext2, key)
require.NoError(t, err)
assert.Equal(t, plaintext, decrypted1)
assert.Equal(t, plaintext, decrypted2)
})
t.Run("decrypt with wrong key fails", func(t *testing.T) {
plaintext := "secret-token"
wrongKey := make([]byte, 32)
_, err := rand.Read(wrongKey)
require.NoError(t, err)
ciphertext, err := EncryptToken(plaintext, key)
require.NoError(t, err)
// Try to decrypt with wrong key
_, err = DecryptToken(ciphertext, wrongKey)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to decrypt")
})
t.Run("tampered ciphertext fails authentication", func(t *testing.T) {
plaintext := "secret-token"
ciphertext, err := EncryptToken(plaintext, key)
require.NoError(t, err)
// Tamper with the ciphertext
tampered := make([]byte, len(ciphertext))
copy(tampered, ciphertext)
tampered[len(tampered)-1] ^= 0xFF // Flip bits in the last byte
// Decryption should fail due to authentication tag mismatch
_, err = DecryptToken(tampered, key)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to decrypt")
})
t.Run("handles special characters", func(t *testing.T) {
plaintext := "token-with-special-chars: !@#$%^&*()_+-={}[]|\\:\";<>?,./~`"
ciphertext, err := EncryptToken(plaintext, key)
require.NoError(t, err)
decrypted, err := DecryptToken(ciphertext, key)
require.NoError(t, err)
assert.Equal(t, plaintext, decrypted)
})
t.Run("handles unicode characters", func(t *testing.T) {
plaintext := "token-with-unicode: 你好世界 🔐🔑"
ciphertext, err := EncryptToken(plaintext, key)
require.NoError(t, err)
decrypted, err := DecryptToken(ciphertext, key)
require.NoError(t, err)
assert.Equal(t, plaintext, decrypted)
})
t.Run("handles long tokens", func(t *testing.T) {
plaintext := strings.Repeat("a", 10000) // 10KB token
ciphertext, err := EncryptToken(plaintext, key)
require.NoError(t, err)
decrypted, err := DecryptToken(ciphertext, key)
require.NoError(t, err)
assert.Equal(t, plaintext, decrypted)
})
}
func TestEncryptToken_InvalidInputs(t *testing.T) {
key := make([]byte, 32)
_, err := rand.Read(key)
require.NoError(t, err)
t.Run("empty plaintext", func(t *testing.T) {
_, err := EncryptToken("", key)
assert.Error(t, err)
assert.Contains(t, err.Error(), "cannot encrypt empty plaintext")
})
t.Run("invalid key length - too short", func(t *testing.T) {
shortKey := make([]byte, 16) // Only 16 bytes (AES-128)
_, err := EncryptToken("plaintext", shortKey)
assert.Error(t, err)
assert.Contains(t, err.Error(), "encryption key must be 32 bytes")
})
t.Run("invalid key length - too long", func(t *testing.T) {
longKey := make([]byte, 64) // 64 bytes (too long)
_, err := EncryptToken("plaintext", longKey)
assert.Error(t, err)
assert.Contains(t, err.Error(), "encryption key must be 32 bytes")
})
t.Run("nil key", func(t *testing.T) {
_, err := EncryptToken("plaintext", nil)
assert.Error(t, err)
})
}
func TestDecryptToken_InvalidInputs(t *testing.T) {
key := make([]byte, 32)
_, err := rand.Read(key)
require.NoError(t, err)
t.Run("empty ciphertext", func(t *testing.T) {
_, err := DecryptToken([]byte{}, key)
assert.Error(t, err)
assert.Contains(t, err.Error(), "cannot decrypt empty ciphertext")
})
t.Run("ciphertext too short", func(t *testing.T) {
shortCiphertext := []byte{0x01, 0x02, 0x03} // Only 3 bytes
_, err := DecryptToken(shortCiphertext, key)
assert.Error(t, err)
assert.Contains(t, err.Error(), "ciphertext too short")
})
t.Run("invalid key length", func(t *testing.T) {
ciphertext, err := EncryptToken("test", key)
require.NoError(t, err)
shortKey := make([]byte, 16)
_, err = DecryptToken(ciphertext, shortKey)
assert.Error(t, err)
assert.Contains(t, err.Error(), "decryption key must be 32 bytes")
})
t.Run("nil key", func(t *testing.T) {
ciphertext, err := EncryptToken("test", key)
require.NoError(t, err)
_, err = DecryptToken(ciphertext, nil)
assert.Error(t, err)
})
t.Run("corrupted data", func(t *testing.T) {
corruptedData := make([]byte, 50)
_, err := rand.Read(corruptedData)
require.NoError(t, err)
_, err = DecryptToken(corruptedData, key)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to decrypt")
})
}
func TestEncryption_SecurityProperties(t *testing.T) {
key := make([]byte, 32)
_, err := rand.Read(key)
require.NoError(t, err)
t.Run("nonce uniqueness", func(t *testing.T) {
plaintext := "test-token"
nonces := make(map[string]bool)
// Generate 1000 encryptions and check nonce uniqueness
for i := 0; i < 1000; i++ {
ciphertext, err := EncryptToken(plaintext, key)
require.NoError(t, err)
// Extract nonce (first 12 bytes for GCM)
nonce := string(ciphertext[:12])
assert.False(t, nonces[nonce], "duplicate nonce detected")
nonces[nonce] = true
}
assert.Len(t, nonces, 1000)
})
t.Run("ciphertext length", func(t *testing.T) {
plaintext := "test-token-123"
ciphertext, err := EncryptToken(plaintext, key)
require.NoError(t, err)
// GCM: nonce (12) + ciphertext (len(plaintext)) + tag (16)
expectedLength := 12 + len(plaintext) + 16
assert.Equal(t, expectedLength, len(ciphertext))
})
}
func BenchmarkEncryptToken(b *testing.B) {
key := make([]byte, 32)
rand.Read(key)
plaintext := "my-refresh-token-1234567890"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = EncryptToken(plaintext, key)
}
}
func BenchmarkDecryptToken(b *testing.B) {
key := make([]byte, 32)
rand.Read(key)
plaintext := "my-refresh-token-1234567890"
ciphertext, _ := EncryptToken(plaintext, key)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = DecryptToken(ciphertext, key)
}
}

View File

@@ -0,0 +1,58 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package crypto
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"regexp"
)
const (
// PKCE code verifier length (RFC 7636 recommends 43-128 characters)
codeVerifierLength = 43
// Valid characters for code verifier: [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~"
codeVerifierCharset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
)
var (
// Regex to validate code verifier format (RFC 7636)
codeVerifierRegex = regexp.MustCompile(`^[A-Za-z0-9\-\._~]{43,128}$`)
)
// GenerateCodeVerifier generates a cryptographically secure PKCE code verifier
// The verifier is a random string of 43-128 characters using the unreserved character set.
// Returns a base64 URL-safe encoded string suitable for OAuth2 PKCE flow.
func GenerateCodeVerifier() (string, error) {
// Generate random bytes (32 bytes = 43 characters in base64)
randomBytes := make([]byte, 32)
if _, err := rand.Read(randomBytes); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
// Encode to base64 URL-safe (no padding)
verifier := base64.RawURLEncoding.EncodeToString(randomBytes)
// Validate the generated verifier
if !ValidateCodeVerifier(verifier) {
return "", fmt.Errorf("generated verifier failed validation")
}
return verifier, nil
}
// GenerateCodeChallenge generates a PKCE code challenge from a code verifier
// Uses the S256 method: BASE64URL(SHA256(ASCII(code_verifier)))
func GenerateCodeChallenge(verifier string) string {
hash := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(hash[:])
}
// ValidateCodeVerifier validates that a code verifier meets RFC 7636 requirements
// - Length: 43-128 characters
// - Characters: [A-Za-z0-9-._~]
func ValidateCodeVerifier(verifier string) bool {
return codeVerifierRegex.MatchString(verifier)
}

View File

@@ -0,0 +1,187 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package crypto
import (
"encoding/base64"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerateCodeVerifier(t *testing.T) {
t.Run("generates valid verifier", func(t *testing.T) {
verifier, err := GenerateCodeVerifier()
require.NoError(t, err)
assert.NotEmpty(t, verifier)
// Check length (should be 43 characters for 32 bytes)
assert.Len(t, verifier, 43)
// Validate format
assert.True(t, ValidateCodeVerifier(verifier))
})
t.Run("generates unique verifiers", func(t *testing.T) {
verifiers := make(map[string]bool)
iterations := 1000
for i := 0; i < iterations; i++ {
verifier, err := GenerateCodeVerifier()
require.NoError(t, err)
// Check no collision
assert.False(t, verifiers[verifier], "duplicate verifier generated")
verifiers[verifier] = true
}
assert.Len(t, verifiers, iterations)
})
t.Run("contains only URL-safe characters", func(t *testing.T) {
verifier, err := GenerateCodeVerifier()
require.NoError(t, err)
// Check it's valid base64 URL encoding
_, err = base64.RawURLEncoding.DecodeString(verifier)
assert.NoError(t, err)
// Should not contain padding
assert.False(t, strings.Contains(verifier, "="))
})
}
func TestGenerateCodeChallenge(t *testing.T) {
t.Run("generates correct SHA256 challenge", func(t *testing.T) {
// RFC 7636 test vector
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
expectedChallenge := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
challenge := GenerateCodeChallenge(verifier)
assert.Equal(t, expectedChallenge, challenge)
})
t.Run("generates base64 URL-safe challenge", func(t *testing.T) {
verifier, err := GenerateCodeVerifier()
require.NoError(t, err)
challenge := GenerateCodeChallenge(verifier)
assert.NotEmpty(t, challenge)
// Should be valid base64 URL encoding
_, err = base64.RawURLEncoding.DecodeString(challenge)
assert.NoError(t, err)
// Should not contain padding
assert.False(t, strings.Contains(challenge, "="))
// Should be 43 characters (32 bytes SHA256 in base64)
assert.Len(t, challenge, 43)
})
t.Run("different verifiers produce different challenges", func(t *testing.T) {
verifier1, _ := GenerateCodeVerifier()
verifier2, _ := GenerateCodeVerifier()
challenge1 := GenerateCodeChallenge(verifier1)
challenge2 := GenerateCodeChallenge(verifier2)
assert.NotEqual(t, challenge1, challenge2)
})
t.Run("same verifier produces same challenge", func(t *testing.T) {
verifier := "test_verifier_123456789012345678901234567"
challenge1 := GenerateCodeChallenge(verifier)
challenge2 := GenerateCodeChallenge(verifier)
assert.Equal(t, challenge1, challenge2)
})
}
func TestValidateCodeVerifier(t *testing.T) {
tests := []struct {
name string
verifier string
valid bool
}{
{
name: "valid 43 character verifier",
verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
valid: true,
},
{
name: "valid 128 character verifier",
verifier: strings.Repeat("a", 128),
valid: true,
},
{
name: "valid with numeric and alphanumeric (50 chars)",
verifier: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWX",
valid: true,
},
{
name: "valid with special chars (44 chars)",
verifier: "abc123-._~ABC123-._~abc123-._~abc123-._~abc",
valid: true,
},
{
name: "too short (42 chars)",
verifier: strings.Repeat("a", 42),
valid: false,
},
{
name: "too long (129 chars)",
verifier: strings.Repeat("a", 129),
valid: false,
},
{
name: "empty string",
verifier: "",
valid: false,
},
{
name: "contains invalid character (space)",
verifier: "dBjftJeZ4CVP mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
valid: false,
},
{
name: "contains invalid character (+)",
verifier: "dBjftJeZ4CVP+mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
valid: false,
},
{
name: "contains invalid character (/)",
verifier: "dBjftJeZ4CVP/mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
valid: false,
},
{
name: "contains padding (=)",
verifier: "dBjftJeZ4CVP=mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
valid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ValidateCodeVerifier(tt.verifier)
assert.Equal(t, tt.valid, result)
})
}
}
func BenchmarkGenerateCodeVerifier(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = GenerateCodeVerifier()
}
}
func BenchmarkGenerateCodeChallenge(b *testing.B) {
verifier, _ := GenerateCodeVerifier()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = GenerateCodeChallenge(verifier)
}
}

View File

@@ -24,15 +24,16 @@ import (
)
type Server struct {
httpServer *http.Server
db *sql.DB
router *chi.Mux
emailSender email.Sender
emailWorker *email.Worker
baseURL string
adminEmails []string
authService *auth.OauthService
autoLogin bool
httpServer *http.Server
db *sql.DB
router *chi.Mux
emailSender email.Sender
emailWorker *email.Worker
sessionWorker *auth.SessionWorker
baseURL string
adminEmails []string
authService *auth.OauthService
autoLogin bool
}
func NewServer(ctx context.Context, cfg *config.Config, frontend embed.FS) (*Server, error) {
@@ -41,6 +42,15 @@ func NewServer(ctx context.Context, cfg *config.Config, frontend embed.FS) (*Ser
return nil, fmt.Errorf("failed to initialize infrastructure: %w", err)
}
// Initialize repositories
signatureRepo := database.NewSignatureRepository(db)
documentRepo := database.NewDocumentRepository(db)
expectedSignerRepo := database.NewExpectedSignerRepository(db)
reminderRepo := database.NewReminderRepository(db)
emailQueueRepo := database.NewEmailQueueRepository(db)
oauthSessionRepo := database.NewOAuthSessionRepository(db)
// Initialize OAuth auth service with session repository
authService := auth.NewOAuthService(auth.Config{
BaseURL: cfg.App.BaseURL,
ClientID: cfg.OAuth.ClientID,
@@ -53,15 +63,9 @@ func NewServer(ctx context.Context, cfg *config.Config, frontend embed.FS) (*Ser
AllowedDomain: cfg.OAuth.AllowedDomain,
CookieSecret: cfg.OAuth.CookieSecret,
SecureCookies: cfg.App.SecureCookies,
SessionRepo: oauthSessionRepo,
})
// Initialize repositories
signatureRepo := database.NewSignatureRepository(db)
documentRepo := database.NewDocumentRepository(db)
expectedSignerRepo := database.NewExpectedSignerRepository(db)
reminderRepo := database.NewReminderRepository(db)
emailQueueRepo := database.NewEmailQueueRepository(db)
// Initialize services
signatureService := services.NewSignatureService(signatureRepo, documentRepo, signer)
signatureService.SetChecksumConfig(&cfg.Checksum)
@@ -90,6 +94,16 @@ func NewServer(ctx context.Context, cfg *config.Config, frontend embed.FS) (*Ser
)
}
// Initialize OAuth session cleanup worker
var sessionWorker *auth.SessionWorker
if oauthSessionRepo != nil {
workerConfig := auth.DefaultSessionWorkerConfig()
sessionWorker = auth.NewSessionWorker(oauthSessionRepo, workerConfig)
if err := sessionWorker.Start(); err != nil {
return nil, fmt.Errorf("failed to start OAuth session worker: %w", err)
}
}
router := chi.NewRouter()
router.Use(i18n.Middleware(i18nService))
@@ -118,15 +132,16 @@ func NewServer(ctx context.Context, cfg *config.Config, frontend embed.FS) (*Ser
}
return &Server{
httpServer: httpServer,
db: db,
router: router,
emailSender: emailSender,
emailWorker: emailWorker,
baseURL: cfg.App.BaseURL,
adminEmails: cfg.App.AdminEmails,
authService: authService,
autoLogin: cfg.OAuth.AutoLogin,
httpServer: httpServer,
db: db,
router: router,
emailSender: emailSender,
emailWorker: emailWorker,
sessionWorker: sessionWorker,
baseURL: cfg.App.BaseURL,
adminEmails: cfg.App.AdminEmails,
authService: authService,
autoLogin: cfg.OAuth.AutoLogin,
}, nil
}
@@ -135,7 +150,14 @@ func (s *Server) Start() error {
}
func (s *Server) Shutdown(ctx context.Context) error {
// Stop email worker first if it exists
// Stop OAuth session worker first if it exists
if s.sessionWorker != nil {
if err := s.sessionWorker.Stop(); err != nil {
fmt.Printf("Warning: failed to stop OAuth session worker: %v\n", err)
}
}
// Stop email worker if it exists
if s.emailWorker != nil {
if err := s.emailWorker.Stop(); err != nil {
// Log but don't fail shutdown

View File

@@ -7,12 +7,7 @@ services:
container_name: ackify-ce-migrate
environment:
ACKIFY_LOG_LEVEL: "${ACKIFY_LOG_LEVEL}"
ACKIFY_BASE_URL: "${ACKIFY_BASE_URL}"
ACKIFY_ORGANISATION: "${ACKIFY_ORGANISATION}"
ACKIFY_DB_DSN: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@ackify-db:5432/${POSTGRES_DB}?sslmode=disable"
ACKIFY_OAUTH_PROVIDER: "${ACKIFY_OAUTH_PROVIDER}"
ACKIFY_OAUTH_CLIENT_ID: "${ACKIFY_OAUTH_CLIENT_ID}"
ACKIFY_OAUTH_CLIENT_SECRET: "${ACKIFY_OAUTH_CLIENT_SECRET}"
depends_on:
ackify-db:
condition: service_healthy
@@ -28,23 +23,27 @@ services:
restart: unless-stopped
environment:
ACKIFY_LOG_LEVEL: "${ACKIFY_LOG_LEVEL}"
ACKIFY_BASE_URL: "https://${APP_DNS}"
ACKIFY_BASE_URL: "${ACKIFY_BASE_URL}"
ACKIFY_ORGANISATION: "${ACKIFY_ORGANISATION}"
ACKIFY_DB_DSN: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@ackify-db:5432/${POSTGRES_DB}?sslmode=disable"
ACKIFY_OAUTH_PROVIDER: "${ACKIFY_OAUTH_PROVIDER}"
ACKIFY_OAUTH_CLIENT_ID: "${ACKIFY_OAUTH_CLIENT_ID}"
ACKIFY_OAUTH_CLIENT_SECRET: "${ACKIFY_OAUTH_CLIENT_SECRET}"
# -- NEEDED FOR CUSTOM PROVIDER
# ACKIFY_OAUTH_AUTH_URL: "${ACKIFY_OAUTH_AUTH_URL}"
# ACKIFY_OAUTH_TOKEN_URL: "${ACKIFY_OAUTH_TOKEN_URL}"
# ACKIFY_OAUTH_USERINFO_URL: "${ACKIFY_OAUTH_USERINFO_URL}"
# ACKIFY_OAUTH_SCOPES: "${ACKIFY_OAUTH_SCOPES}"
# -- END NEEDED FOR CUSTOM PROVIDER
ACKIFY_OAUTH_ALLOWED_DOMAIN: "${ACKIFY_OAUTH_ALLOWED_DOMAIN}"
ACKIFY_OAUTH_AUTH_URL: "${ACKIFY_OAUTH_AUTH_URL:-}"
ACKIFY_OAUTH_TOKEN_URL: "${ACKIFY_OAUTH_TOKEN_URL:-}"
ACKIFY_OAUTH_USERINFO_URL: "${ACKIFY_OAUTH_USERINFO_URL:-}"
ACKIFY_OAUTH_LOGOUT_URL: "${ACKIFY_OAUTH_LOGOUT_URL:-}"
ACKIFY_OAUTH_ALLOWED_DOMAIN: "${ACKIFY_OAUTH_ALLOWED_DOMAIN:-}"
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}"
ACKIFY_MAIL_HOST: "${ACKIFY_MAIL_HOST:-mailhog}"
ACKIFY_MAIL_PORT: "${ACKIFY_MAIL_PORT:-1025}"
ACKIFY_MAIL_TLS: "false"
ACKIFY_MAIL_STARTTLS: "false"
ACKIFY_MAIL_FROM: "${ACKIFY_MAIL_FROM:-noreply@ackify.local}"
ACKIFY_MAIL_FROM_NAME: "${ACKIFY_MAIL_FROM_NAME:-Ackify}"
depends_on:
ackify-migrate:
condition: service_completed_successfully

29
docs/README.md Normal file
View File

@@ -0,0 +1,29 @@
# Ackify Documentation
Complete documentation for Ackify - Proof of Read with cryptographic signatures.
🇬🇧 **[English Documentation](en/)** | 🇫🇷 **[Documentation Française](fr/)**
---
## Quick Links
### 🇬🇧 English
- [Getting Started](en/getting-started.md)
- [Configuration](en/configuration.md)
- [Features](en/features/)
- [API Reference](en/api.md)
- [Deployment](en/deployment.md)
### 🇫🇷 Français
- [Démarrage Rapide](fr/getting-started.md)
- [Configuration](fr/configuration.md)
- [Fonctionnalités](fr/features/)
- [Référence API](fr/api.md)
- [Déploiement](fr/deployment.md)
---
## Contributing
See [Development Guide](en/development.md) (English) or [Guide de Développement](fr/development.md) (Français).

41
docs/en/README.md Normal file
View File

@@ -0,0 +1,41 @@
# Ackify Documentation (English)
Complete documentation for Ackify - Proof of Read with cryptographic signatures.
🇫🇷 **[Version Française](../fr/)**
## Quick Start
- **[Getting Started](getting-started.md)** - Installation and first steps with Docker Compose
- **[Configuration](configuration.md)** - Environment variables and settings
## Features
- **[Cryptographic Signatures](features/signatures.md)** - Ed25519 signature flow
- **[Expected Signers](features/expected-signers.md)** - Tracking and email reminders
- **[Checksums](features/checksums.md)** - Document integrity verification
- **[Embedding](features/embedding.md)** - oEmbed, iframes, third-party integrations
- **[Internationalization](features/i18n.md)** - Multi-language support (fr, en, es, de, it)
## Advanced Configuration
- **[OAuth Providers](configuration/oauth-providers.md)** - Google, GitHub, GitLab, Custom
- **[Email Setup](configuration/email-setup.md)** - SMTP configuration for reminders
## Architecture & Development
- **[Architecture](architecture.md)** - Tech stack, project structure, Clean Architecture principles
- **[Database](database.md)** - PostgreSQL schema, migrations, constraints
- **[API Reference](api.md)** - REST endpoints, examples, OpenAPI
- **[Deployment](deployment.md)** - Production, security, monitoring
- **[Development](development.md)** - Dev setup, tests, contributing
## Integrations
- **[Google Docs](../integrations/google-doc/)** - Google Workspace integration
- More integrations coming...
## Support
- [GitHub Issues](https://github.com/btouchard/ackify-ce/issues) - Bugs and feature requests
- [GitHub Discussions](https://github.com/btouchard/ackify-ce/discussions) - Questions and discussions

184
docs/en/configuration.md Normal file
View File

@@ -0,0 +1,184 @@
# Configuration
Complete configuration guide for Ackify via environment variables.
## Required Variables
These variables are **required** to start Ackify:
```bash
# Public URL of your instance (used for OAuth callbacks)
APP_DNS=sign.your-domain.com
ACKIFY_BASE_URL=https://sign.your-domain.com
# Your organization name (displayed in the interface)
ACKIFY_ORGANISATION="Your Organization Name"
# PostgreSQL configuration
POSTGRES_USER=ackifyr
POSTGRES_PASSWORD=your_secure_password
POSTGRES_DB=ackify
# OAuth2 Provider
ACKIFY_OAUTH_PROVIDER=google # or github, gitlab, or empty for custom
ACKIFY_OAUTH_CLIENT_ID=your_oauth_client_id
ACKIFY_OAUTH_CLIENT_SECRET=your_oauth_client_secret
# Secret to encrypt session cookies (generate with: openssl rand -base64 32)
ACKIFY_OAUTH_COOKIE_SECRET=your_base64_encoded_secret_key
```
## Optional Variables
### Server
```bash
# HTTP listening address (default: :8080)
ACKIFY_LISTEN_ADDR=:8080
# Log level: debug, info, warn, error (default: info)
ACKIFY_LOG_LEVEL=info
```
### Security & OAuth2
```bash
# Restrict access to a specific email domain
ACKIFY_OAUTH_ALLOWED_DOMAIN=@company.com
# Enable silent auto-login (default: false)
ACKIFY_OAUTH_AUTO_LOGIN=false
# Custom logout URL (optional)
ACKIFY_OAUTH_LOGOUT_URL=https://your-provider.com/logout
# Custom OAuth2 scopes (default: openid,email,profile)
ACKIFY_OAUTH_SCOPES=openid,email,profile
```
### Administration
```bash
# Admin email list (comma-separated)
ACKIFY_ADMIN_EMAILS=admin@company.com,admin2@company.com
```
Admins have access to:
- Admin dashboard (`/admin`)
- Document metadata management
- Expected signers tracking
- Email reminders sending
- Document deletion
### Document Checksum (Optional)
Configuration for automatic checksum computation when creating documents from URLs:
```bash
# Maximum file size to download for checksum calculation (default: 10485760 = 10MB)
ACKIFY_CHECKSUM_MAX_BYTES=10485760
# Timeout for checksum download in milliseconds (default: 5000ms = 5s)
ACKIFY_CHECKSUM_TIMEOUT_MS=5000
# Maximum number of HTTP redirects to follow (default: 3)
ACKIFY_CHECKSUM_MAX_REDIRECTS=3
# Comma-separated list of allowed MIME types (default includes PDF, images, Office docs, ODF)
ACKIFY_CHECKSUM_ALLOWED_TYPES=application/pdf,image/*,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.*
```
**Note**: These settings only apply when admins create documents via the admin dashboard with a remote URL. The system will attempt to download and calculate the SHA-256 checksum automatically.
## Advanced Configuration
### OAuth2 Providers
See [OAuth Providers](configuration/oauth-providers.md) for detailed configuration of:
- Google OAuth2
- GitHub OAuth2
- GitLab OAuth2 (public + self-hosted)
- Custom OAuth2 provider
### Email (SMTP)
See [Email Setup](configuration/email-setup.md) to configure email reminders sending.
## Complete Example
Example `.env` for a production installation:
```bash
# Application
APP_DNS=sign.company.com
ACKIFY_BASE_URL=https://sign.company.com
ACKIFY_ORGANISATION="ACME Corporation"
ACKIFY_LOG_LEVEL=info
ACKIFY_LISTEN_ADDR=:8080
# Database
POSTGRES_USER=ackifyr
POSTGRES_PASSWORD=super_secure_password_123
POSTGRES_DB=ackify
# OAuth2 (Google)
ACKIFY_OAUTH_PROVIDER=google
ACKIFY_OAUTH_CLIENT_ID=123456789-abc.apps.googleusercontent.com
ACKIFY_OAUTH_CLIENT_SECRET=GOCSPX-xyz123
ACKIFY_OAUTH_ALLOWED_DOMAIN=@company.com
# Security
ACKIFY_OAUTH_COOKIE_SECRET=ZXhhbXBsZV9iYXNlNjRfc2VjcmV0X2tleQ==
# Administration
ACKIFY_ADMIN_EMAILS=admin@company.com,cto@company.com
# Email (optional - omit MAIL_HOST to disable)
ACKIFY_MAIL_HOST=smtp.gmail.com
ACKIFY_MAIL_PORT=587
ACKIFY_MAIL_USERNAME=noreply@company.com
ACKIFY_MAIL_PASSWORD=app_specific_password
ACKIFY_MAIL_FROM=noreply@company.com
ACKIFY_MAIL_FROM_NAME="Ackify - ACME"
ACKIFY_MAIL_TEMPLATE_DIR=templates/emails
ACKIFY_MAIL_DEFAULT_LOCALE=en
# Document Checksum (optional - for auto-checksum from URLs)
ACKIFY_CHECKSUM_MAX_BYTES=10485760
ACKIFY_CHECKSUM_TIMEOUT_MS=5000
ACKIFY_CHECKSUM_MAX_REDIRECTS=3
```
## Configuration Validation
After modifying `.env`, restart:
```bash
docker compose restart ackify-ce
```
Check logs:
```bash
docker compose logs -f ackify-ce
```
Test the health check:
```bash
curl http://localhost:8080/api/v1/health
```
## Production Variables
**Production security checklist**:
- ✅ Use HTTPS (`ACKIFY_BASE_URL=https://...`)
- ✅ Generate strong secrets (64+ characters)
- ✅ Restrict OAuth domain (`ACKIFY_OAUTH_ALLOWED_DOMAIN`)
- ✅ Configure admin emails (`ACKIFY_ADMIN_EMAILS`)
- ✅ Use PostgreSQL with SSL in production
- ✅ Log in `info` mode (not `debug`)
- ✅ Regularly backup the database
See [Deployment](deployment.md) for more details on production deployment.

View File

@@ -0,0 +1,353 @@
# Email Setup
SMTP configuration for sending email reminders to expected signers.
## Overview
Ackify's email service allows sending automatic reminders to users who have not yet signed a document.
**Features**:
- Multilingual reminder sending (fr, en, es, de, it)
- HTML and plain text templates
- Send history in PostgreSQL
- TLS/STARTTLS support
- Configurable timeout
**Note**: Email service is **optional**. If `ACKIFY_MAIL_HOST` is not defined, emails are disabled.
## Basic Configuration
### Required Variables
```bash
# SMTP server
ACKIFY_MAIL_HOST=smtp.gmail.com
ACKIFY_MAIL_PORT=587
ACKIFY_MAIL_USERNAME=your-email@gmail.com
ACKIFY_MAIL_PASSWORD=your-app-password
# Sender address
ACKIFY_MAIL_FROM=noreply@company.com
```
### Optional Variables
```bash
# Displayed sender name (default: ACKIFY_ORGANISATION)
ACKIFY_MAIL_FROM_NAME="Ackify - ACME Corporation"
# Email subject prefix (optional)
ACKIFY_MAIL_SUBJECT_PREFIX="[Ackify]"
# Enable TLS (default: true)
ACKIFY_MAIL_TLS=true
# Enable STARTTLS (default: true)
ACKIFY_MAIL_STARTTLS=true
# Connection timeout (default: 10s)
ACKIFY_MAIL_TIMEOUT=10s
# Email template directory (default: templates/emails)
ACKIFY_MAIL_TEMPLATE_DIR=templates/emails
# Default email language/locale (default: en)
# Supported: en, fr, es, de, it
ACKIFY_MAIL_DEFAULT_LOCALE=en
```
## Popular SMTP Providers
### Gmail
**Configuration**:
```bash
ACKIFY_MAIL_HOST=smtp.gmail.com
ACKIFY_MAIL_PORT=587
ACKIFY_MAIL_USERNAME=your-email@gmail.com
ACKIFY_MAIL_PASSWORD=your-app-password
ACKIFY_MAIL_TLS=true
ACKIFY_MAIL_STARTTLS=true
```
**Prerequisites**:
1. Enable 2-step verification on your Google account
2. Generate an "App Password": https://myaccount.google.com/apppasswords
3. Use this password in `ACKIFY_MAIL_PASSWORD`
### SendGrid
```bash
ACKIFY_MAIL_HOST=smtp.sendgrid.net
ACKIFY_MAIL_PORT=587
ACKIFY_MAIL_USERNAME=apikey
ACKIFY_MAIL_PASSWORD=your-sendgrid-api-key
ACKIFY_MAIL_FROM=noreply@your-domain.com
ACKIFY_MAIL_TLS=true
```
### Amazon SES
```bash
ACKIFY_MAIL_HOST=email-smtp.us-east-1.amazonaws.com
ACKIFY_MAIL_PORT=587
ACKIFY_MAIL_USERNAME=your-smtp-username
ACKIFY_MAIL_PASSWORD=your-smtp-password
ACKIFY_MAIL_FROM=noreply@verified-domain.com
ACKIFY_MAIL_TLS=true
```
**Important**: Verify your domain in AWS SES before sending.
### Mailgun
```bash
ACKIFY_MAIL_HOST=smtp.mailgun.org
ACKIFY_MAIL_PORT=587
ACKIFY_MAIL_USERNAME=postmaster@your-domain.mailgun.org
ACKIFY_MAIL_PASSWORD=your-mailgun-smtp-password
ACKIFY_MAIL_FROM=noreply@your-domain.com
ACKIFY_MAIL_TLS=true
```
### Custom SMTP (Self-hosted)
```bash
ACKIFY_MAIL_HOST=mail.company.com
ACKIFY_MAIL_PORT=587
ACKIFY_MAIL_USERNAME=ackify@company.com
ACKIFY_MAIL_PASSWORD=secure_password
ACKIFY_MAIL_FROM=ackify@company.com
ACKIFY_MAIL_TLS=true
ACKIFY_MAIL_STARTTLS=true
```
## Email Templates
Templates are in `/backend/templates/emails/` with multilingual support.
### Structure
```
templates/emails/
├── fr/
│ ├── reminder.html # French HTML template
│ └── reminder.txt # French plain text template
├── en/
│ ├── reminder.html # English HTML template
│ └── reminder.txt # English plain text template
└── ...
```
### Available Variables
In templates, you can use:
```go
{{.RecipientName}} // Recipient name
{{.DocumentID}} // Document ID
{{.DocumentTitle}} // Document title
{{.DocumentURL}} // Document URL (if defined in metadata)
{{.SignURL}} // URL to sign
{{.OrganisationName}} // Organization name
{{.SenderName}} // Sender name (admin)
```
### HTML Template Example
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Signature reminder</title>
</head>
<body>
<h1>Hello {{.RecipientName}},</h1>
<p>
You are expected to sign the document
<strong>{{.DocumentTitle}}</strong>.
</p>
<p>
<a href="{{.SignURL}}" style="background: #0066cc; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
Sign now
</a>
</p>
<p>
Best regards,<br>
{{.OrganisationName}}
</p>
</body>
</html>
```
### Customize Templates
To use custom templates:
```bash
ACKIFY_MAIL_TEMPLATE_DIR=/custom/path/to/email/templates
```
Make sure to maintain the same directory structure (locale/reminder.html).
## Sending Reminders
### Via Admin Dashboard
1. Go to `/admin`
2. Select a document
3. Click "Expected Signers"
4. Select recipients
5. Click "Send Reminders"
### Via API
```bash
curl -X POST http://localhost:8080/api/v1/admin/documents/doc_id/reminders \
-b cookies.txt \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: YOUR_TOKEN" \
-d '{
"emails": ["user1@company.com", "user2@company.com"],
"locale": "fr"
}'
```
**Response**:
```json
{
"sent": 2,
"failed": 0,
"errors": []
}
```
## Reminder History
Sends are tracked in the `reminder_logs` table:
```sql
SELECT
recipient_email,
sent_at,
status,
error_message
FROM reminder_logs
WHERE doc_id = 'my_document'
ORDER BY sent_at DESC;
```
**Possible statuses**:
- `sent` - Successfully sent
- `failed` - Send failure
- `bounced` - Bounced (invalid email)
## Testing the Configuration
### Manual Test via API
```bash
# 1. Login as admin
# 2. Add an expected signer with your email
curl -X POST http://localhost:8080/api/v1/admin/documents/test_doc/signers \
-b cookies.txt \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: YOUR_TOKEN" \
-d '{
"email": "your-email@company.com",
"name": "Test User"
}'
# 3. Send a test reminder
curl -X POST http://localhost:8080/api/v1/admin/documents/test_doc/reminders \
-b cookies.txt \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: YOUR_TOKEN" \
-d '{
"emails": ["your-email@company.com"],
"locale": "en"
}'
```
### Check Logs
```bash
docker compose logs -f ackify-ce | grep -i mail
```
You should see:
```
INFO Email sent successfully to: your-email@company.com
```
## Troubleshooting
### Error "SMTP connection failed"
Verify:
- `ACKIFY_MAIL_HOST` and `ACKIFY_MAIL_PORT` are correct
- Your server allows outgoing connections on the SMTP port
- `ACKIFY_MAIL_TLS=true` if the server requires TLS
### Error "Authentication failed"
Verify:
- `ACKIFY_MAIL_USERNAME` and `ACKIFY_MAIL_PASSWORD` are correct
- For Gmail: use an "App Password", not your main password
- For SendGrid: username must be `apikey`
### Email not received but status "sent"
Verify:
- Spam/junk folder
- SPF/DKIM/DMARC of your domain (to avoid spam filters)
- The `ACKIFY_MAIL_FROM` address is verified with your provider
### Template not found
Verify:
- `ACKIFY_MAIL_TEMPLATE_DIR` points to the correct directory
- The structure `{locale}/reminder.html` exists
- Files have the correct permissions (readable)
### Timeout during send
Increase timeout:
```bash
ACKIFY_MAIL_TIMEOUT=30s
```
## Best Practices
### Production
- ✅ Use a dedicated SMTP service (SendGrid, Mailgun, SES)
- ✅ Verify your domain (SPF, DKIM, DMARC)
- ✅ Use a `noreply@` address for `ACKIFY_MAIL_FROM`
- ✅ Monitor `reminder_logs` to detect failures
- ✅ Regularly test email sending
### Security
- ✅ Never commit `ACKIFY_MAIL_PASSWORD` to git
- ✅ Use Docker secrets or environment variables
- ✅ Restrict SMTP account permissions
- ✅ Enable TLS/STARTTLS in production
### Performance
- Emails are sent **synchronously** during API call
- For large volumes, consider an asynchronous queue
- Limit number of recipients per batch (recommended: < 100)
## Disabling Emails
To completely disable the email service:
```bash
# Remove or comment out ACKIFY_MAIL_HOST
# ACKIFY_MAIL_HOST=
```
The admin dashboard will no longer display reminder sending options.

View File

@@ -0,0 +1,254 @@
# OAuth2 Providers
Detailed configuration of different OAuth2 providers supported by Ackify.
## Supported Providers
| Provider | Configuration | Auto-detection |
|----------|--------------|----------------|
| Google | `ACKIFY_OAUTH_PROVIDER=google` | ✅ |
| GitHub | `ACKIFY_OAUTH_PROVIDER=github` | ✅ |
| GitLab | `ACKIFY_OAUTH_PROVIDER=gitlab` | ✅ |
| Custom | Leave empty + manual URLs | ❌ |
## Google OAuth2
### Configuration steps
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a project or select an existing one
3. Enable "Google+ API" (to retrieve user profile)
4. Create OAuth 2.0 credentials:
- **Application type**: Web application
- **Authorized JavaScript origins**: `https://sign.your-domain.com`
- **Authorized redirect URIs**: `https://sign.your-domain.com/api/v1/auth/callback`
### `.env` Configuration
```bash
ACKIFY_OAUTH_PROVIDER=google
ACKIFY_OAUTH_CLIENT_ID=123456789-abc.apps.googleusercontent.com
ACKIFY_OAUTH_CLIENT_SECRET=GOCSPX-xyz123abc
# Optional: restrict to @company.com emails
ACKIFY_OAUTH_ALLOWED_DOMAIN=@company.com
```
### Automatic scopes
By default, Ackify requests:
- `openid` - OAuth2 identity
- `email` - Email address
- `profile` - Full name
## GitHub OAuth2
### Configuration steps
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
2. Click "New OAuth App"
3. Fill the form:
- **Application name**: Ackify
- **Homepage URL**: `https://sign.your-domain.com`
- **Authorization callback URL**: `https://sign.your-domain.com/api/v1/auth/callback`
4. Generate a client secret
### `.env` Configuration
```bash
ACKIFY_OAUTH_PROVIDER=github
ACKIFY_OAUTH_CLIENT_ID=Iv1.abc123xyz
ACKIFY_OAUTH_CLIENT_SECRET=ghp_1234567890abcdef
# Optional: restrict to verified emails from an organization
ACKIFY_OAUTH_ALLOWED_DOMAIN=@company.com
```
### Automatic scopes
By default:
- `read:user` - Profile reading
- `user:email` - Email access
## GitLab OAuth2
### GitLab.com (public)
1. Go to [GitLab Applications](https://gitlab.com/-/profile/applications)
2. Create a new application:
- **Name**: Ackify
- **Redirect URI**: `https://sign.your-domain.com/api/v1/auth/callback`
- **Scopes**: `openid`, `email`, `profile`
3. Copy the Application ID and Secret
```bash
ACKIFY_OAUTH_PROVIDER=gitlab
ACKIFY_OAUTH_CLIENT_ID=abc123xyz
ACKIFY_OAUTH_CLIENT_SECRET=glpat-xyz123
```
### GitLab Self-Hosted
For a private GitLab instance:
```bash
ACKIFY_OAUTH_PROVIDER=gitlab
ACKIFY_OAUTH_GITLAB_URL=https://gitlab.company.com
ACKIFY_OAUTH_CLIENT_ID=abc123xyz
ACKIFY_OAUTH_CLIENT_SECRET=glpat-xyz123
```
**Important**: `ACKIFY_OAUTH_GITLAB_URL` must point to your GitLab instance without trailing slash.
## Custom OAuth2 Provider
To use a non-standard OAuth2 provider (Keycloak, Okta, Auth0, etc.).
### Complete configuration
```bash
# Do not define ACKIFY_OAUTH_PROVIDER (or leave empty)
ACKIFY_OAUTH_PROVIDER=
# Manual URLs
ACKIFY_OAUTH_AUTH_URL=https://auth.company.com/oauth/authorize
ACKIFY_OAUTH_TOKEN_URL=https://auth.company.com/oauth/token
ACKIFY_OAUTH_USERINFO_URL=https://auth.company.com/api/user
# Custom scopes (optional)
ACKIFY_OAUTH_SCOPES=openid,email,profile
# Custom logout URL (optional)
ACKIFY_OAUTH_LOGOUT_URL=https://auth.company.com/logout
# Credentials
ACKIFY_OAUTH_CLIENT_ID=your_client_id
ACKIFY_OAUTH_CLIENT_SECRET=your_client_secret
```
### Example with Keycloak
```bash
ACKIFY_OAUTH_PROVIDER=
ACKIFY_OAUTH_AUTH_URL=https://keycloak.company.com/realms/myrealm/protocol/openid-connect/auth
ACKIFY_OAUTH_TOKEN_URL=https://keycloak.company.com/realms/myrealm/protocol/openid-connect/token
ACKIFY_OAUTH_USERINFO_URL=https://keycloak.company.com/realms/myrealm/protocol/openid-connect/userinfo
ACKIFY_OAUTH_LOGOUT_URL=https://keycloak.company.com/realms/myrealm/protocol/openid-connect/logout
ACKIFY_OAUTH_SCOPES=openid,email,profile
ACKIFY_OAUTH_CLIENT_ID=ackify-client
ACKIFY_OAUTH_CLIENT_SECRET=secret123
```
### Example with Okta
```bash
ACKIFY_OAUTH_PROVIDER=
ACKIFY_OAUTH_AUTH_URL=https://dev-123456.okta.com/oauth2/default/v1/authorize
ACKIFY_OAUTH_TOKEN_URL=https://dev-123456.okta.com/oauth2/default/v1/token
ACKIFY_OAUTH_USERINFO_URL=https://dev-123456.okta.com/oauth2/default/v1/userinfo
ACKIFY_OAUTH_SCOPES=openid,email,profile
ACKIFY_OAUTH_CLIENT_ID=0oa123xyz
ACKIFY_OAUTH_CLIENT_SECRET=secret123
```
## Domain Restriction
For **all providers**, you can restrict access to emails from a specific domain:
```bash
# Accept only @company.com emails
ACKIFY_OAUTH_ALLOWED_DOMAIN=@company.com
```
**Behavior**:
- Users with a different email will see an error when logging in
- Verification is case-insensitive
- Works with all providers (Google, GitHub, GitLab, custom)
## Auto-Login
Enable silent auto-login for better UX:
```bash
ACKIFY_OAUTH_AUTO_LOGIN=true
```
**How it works**:
- If the user already has an active OAuth session, automatic redirect
- No click required on "Sign in"
- Useful for corporate integrations (Google Workspace, Microsoft 365)
**Warning**: Can create infinite redirects if misconfigured.
## OAuth2 Security
### PKCE (Proof Key for Code Exchange)
Ackify **automatically** implements PKCE for all providers:
- Protection against authorization code interception
- Method: S256 (SHA-256)
- Enabled by default, no configuration required
### Refresh Tokens
Refresh tokens are:
- Stored **encrypted** in PostgreSQL (AES-256-GCM)
- Used to maintain sessions for 30 days
- Automatically cleaned up after expiration (37 days)
- Protected by IP + User-Agent tracking
### Secure Sessions
```bash
# Strong secret required (minimum 32 bytes in base64)
ACKIFY_OAUTH_COOKIE_SECRET=$(openssl rand -base64 32)
```
Session cookies use:
- HMAC-SHA256 for integrity
- AES-256-GCM encryption
- `Secure` flags (HTTPS only) and `HttpOnly`
- `SameSite=Lax` for CSRF protection
## Troubleshooting
### Error "invalid redirect_uri"
Verify that:
- `ACKIFY_BASE_URL` exactly matches your domain
- The callback URL in the provider includes `/api/v1/auth/callback`
- No trailing slash in `ACKIFY_BASE_URL`
### Error "unauthorized_client"
Verify:
- The `client_id` and `client_secret` are correct
- The OAuth application is properly enabled on the provider side
- The requested scopes are authorized
### Error "access_denied"
The user refused authorization, or:
- Their email doesn't match `ACKIFY_OAUTH_ALLOWED_DOMAIN`
- The application doesn't have the required permissions
### Custom provider doesn't work
Verify:
- `ACKIFY_OAUTH_PROVIDER` is **empty** or undefined
- The 3 URLs (auth, token, userinfo) are complete and correct
- The response from `/userinfo` contains `sub`, `email`, `name`
## Testing the Configuration
```bash
# Restart after changes
docker compose restart ackify-ce
# Test OAuth connection
curl -X POST http://localhost:8080/api/v1/auth/start \
-H "Content-Type: application/json" \
-d '{"redirect_to": "/"}'
# Should return a redirect_url to the OAuth provider
```

View File

@@ -0,0 +1,235 @@
# Checksums
Document integrity verification with tracking.
## Overview
Ackify allows storing and verifying document checksums (fingerprints) to ensure their integrity.
**Supported algorithms**:
- SHA-256 (recommended)
- SHA-512
- MD5 (legacy)
## Calculating a Checksum
### Command Line
```bash
# Linux/Mac - SHA-256
sha256sum document.pdf
# Output: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 document.pdf
# SHA-512
sha512sum document.pdf
# MD5
md5sum document.pdf
# Windows PowerShell
Get-FileHash document.pdf -Algorithm SHA256
Get-FileHash document.pdf -Algorithm SHA512
Get-FileHash document.pdf -Algorithm MD5
```
### Client-Side (JavaScript)
The Vue.js frontend uses the **Web Crypto API**:
```javascript
async function calculateChecksum(file) {
const arrayBuffer = await file.arrayBuffer()
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}
// Usage
const file = document.querySelector('input[type="file"]').files[0]
const checksum = await calculateChecksum(file)
console.log('SHA-256:', checksum)
```
## Storing the Checksum
### Via Admin Dashboard
1. Go to `/admin`
2. Select a document
3. Click "Edit Metadata"
4. Fill in:
- **Checksum**: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
- **Algorithm**: SHA-256
- **Document URL**: https://docs.company.com/policy.pdf
### Via API
```http
PUT /api/v1/admin/documents/policy_2025/metadata
Content-Type: application/json
X-CSRF-Token: abc123
{
"title": "Security Policy 2025",
"url": "https://docs.company.com/policy.pdf",
"checksum": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"checksumAlgorithm": "SHA-256",
"description": "Annual security policy"
}
```
## Verification
### User Interface
The frontend displays:
```
Document: Security Policy 2025
Checksum (SHA-256): e3b0c44...52b855 [Copy]
URL: https://docs.company.com/policy.pdf [Open]
[Upload file to verify]
```
**User workflow**:
1. Downloads document from URL
2. Uploads to verification interface
3. Checksum is calculated client-side
4. Automatic comparison with stored value
5. ✅ Match or ❌ Mismatch
### Manual Verification
```bash
# 1. Download the document
wget https://docs.company.com/policy.pdf
# 2. Calculate checksum
sha256sum policy.pdf
# e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
# 3. Compare with stored value (via API)
curl http://localhost:8080/api/v1/documents/policy_2025
# "checksum": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
# 4. If identical → Document is intact
```
## Use Cases
### Document Compliance
```
Document: "ISO 27001 Certification"
Checksum: SHA-256 of official PDF
```
**Workflow**:
- Store checksum of certified document
- Each reviewer verifies integrity before signing
- Audit trail of all verifications
### Legal Contract
```
Document: "Service Agreement v2.3"
Checksum: SHA-512 for maximum security
URL: https://legal.company.com/contracts/sa-v2.3.pdf
```
**Guarantees**:
- Signed document matches exactly the checksum version
- Detection of any modification
- Traceability of verifications
### Training with Materials
```
Document: "GDPR Training Materials"
Checksum: SHA-256 of ZIP file
```
**Usage**:
- Participants download ZIP
- Verify checksum before starting
- Sign after completion
## Security
### Algorithm Choice
| Algorithm | Security | Performance | Recommendation |
|-----------|----------|-------------|----------------|
| SHA-256 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ✅ Recommended |
| SHA-512 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | Maximum security |
| MD5 | ⭐⭐ | ⭐⭐⭐⭐⭐ | ❌ Legacy only |
**Recommendation**: Use **SHA-256** by default.
### MD5 Limitations
MD5 is **deprecated** for security:
- Collisions possible (two different files = same hash)
- Usable only for legacy compatibility
### Web Crypto API
Client-side verification uses browser's native API:
- No external dependency
- Native performance
- Supported by all modern browsers
## Integration with Signatures
Complete workflow:
```
1. Admin uploads document → calculates checksum → stores metadata
2. User downloads document → verifies checksum client-side
3. If checksum OK → User signs document
4. Signature linked to doc_id with stored checksum
```
**Guarantee**: Signature proves user read **exactly** the checksum version.
## Best Practices
### Storage
- ✅ Always store checksum **before** sending signature link
- ✅ Include document URL in metadata
- ✅ Use SHA-256 minimum
- ✅ Document the algorithm used
### Verification
- ✅ Encourage users to verify before signing
- ✅ Display checksum visibly (with Copy button)
- ✅ Alert on mismatch
### Audit
- ✅ Monitor document integrity
- ✅ Review checksums regularly
## Limitations
- **Manual verification only** - Users must manually calculate and compare checksums
- **No server-side verification API** - Checksum verification is performed client-side or manually
- **No automated audit trail** - The `checksum_verifications` table exists in the database schema but is not currently used by the API
- No checksum signing (future feature: sign checksum with Ed25519)
- No cloud storage integration (S3, GCS) for automatic retrieval
## Current Implementation
Currently, Ackify supports:
- ✅ Storing checksums in document metadata (via admin dashboard or API)
- ✅ Displaying checksums to users for manual verification
- ✅ Client-side checksum calculation using Web Crypto API
- ✅ Automatic checksum computation for remote URLs (admin only)
Future features may include:
- API endpoints for checksum verification tracking
- Automated verification workflows
- Integration with external verification services

View File

@@ -0,0 +1,339 @@
# Embedding & Integrations
Integrate Ackify into your tools (Notion, Outline, Google Docs, etc.).
## Integration Methods
### 1. Direct Link
The simplest:
```
https://sign.company.com/?doc=policy_2025
```
**Usage**:
- Email
- Chat (Slack, Teams)
- Wiki
- Documentation
**Behavior**:
- User clicks → Arrives at signature page
- Logs in via OAuth2
- Signs the document
### 2. iFrame Embed
To integrate in a web page:
```html
<iframe src="https://sign.company.com/?doc=policy_2025"
width="600"
height="200"
frameborder="0"
style="border: 1px solid #ddd; border-radius: 6px;">
</iframe>
```
**Render**:
```
┌─────────────────────────────────────┐
│ 📄 Security Policy 2025 │
│ 42 confirmations │
│ [Sign this document] │
└─────────────────────────────────────┘
```
### 3. oEmbed (Auto-discovery)
For platforms supporting oEmbed (Notion, Outline, Confluence, etc.).
#### How it works
1. Paste URL in your editor:
```
https://sign.company.com/?doc=policy_2025
```
2. Editor auto-detects via meta tag:
```html
<link rel="alternate" type="application/json+oembed"
href="https://sign.company.com/oembed?url=..." />
```
3. Editor calls `/oembed` and receives:
```json
{
"type": "rich",
"version": "1.0",
"title": "Security Policy 2025 - 42 confirmations",
"provider_name": "Ackify",
"html": "<iframe src=\"https://sign.company.com/?doc=policy_2025\" ...>",
"height": 200
}
```
4. Editor displays iframe automatically
#### Supported Platforms
- ✅ **Notion** - Paste URL → Auto-embed
- ✅ **Outline** - Paste URL → Auto-embed
- ✅ **Confluence** - oEmbed macro
- ✅ **AppFlowy** - URL unfurling
- ✅ **Slack** - Link unfurling (Open Graph)
- ✅ **Microsoft Teams** - Card preview
- ✅ **Discord** - Rich embed
## Open Graph & Twitter Cards
Ackify automatically generates meta tags for previews:
```html
<!-- Auto-generated for /?doc=policy_2025 -->
<meta property="og:title" content="Security Policy 2025 - 42 confirmations" />
<meta property="og:description" content="42 people confirmed reading the document" />
<meta property="og:url" content="https://sign.company.com/?doc=policy_2025" />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary" />
```
**Result in Slack/Teams**:
```
┌─────────────────────────────────────┐
│ 🔐 Ackify │
│ Security Policy 2025 │
│ 42 confirmations │
│ sign.company.com │
└─────────────────────────────────────┘
```
## Specific Integrations
### Notion
1. Paste URL in a Notion page:
```
https://sign.company.com/?doc=policy_2025
```
2. Notion auto-detects oEmbed
3. Widget appears with signature button
**Alternative**: Create manual embed
- `/embed` → Paste URL
### Outline
1. In an Outline document, paste:
```
https://sign.company.com/?doc=policy_2025
```
2. Outline automatically loads the widget
### Google Docs
Google Docs doesn't support iframes directly, but:
1. **Option 1 - Link**:
```
Please sign: https://sign.company.com/?doc=policy_2025
```
2. **Option 2 - Image Badge**:
```
![Sign now](https://sign.company.com/badge/policy_2025.png)
```
3. **Option 3 - Google Sites**:
- Create a Google Sites page
- Insert iframe
- Link from Google Docs
See [docs/integrations/google-doc/](../integrations/google-doc/) for more details.
### Confluence
1. Edit a Confluence page
2. Insert "oEmbed" or "HTML Embed" macro
3. Paste:
```
https://sign.company.com/?doc=policy_2025
```
### Slack
**Link Unfurling**:
1. Post URL in a channel:
```
Hey team, please sign: https://sign.company.com/?doc=policy_2025
```
2. Slack automatically displays preview (Open Graph)
**Slash Command** (future):
```
/ackify sign policy_2025
```
### Microsoft Teams
1. Post URL in a conversation
2. Teams displays card preview (Open Graph)
## PNG Badge
Generate a visual badge for README, wiki, etc.
### Badge URL
```
https://sign.company.com/badge/policy_2025.png
```
**Render**:
![Signature Status](https://img.shields.io/badge/Signatures-42%2F50-green)
### Markdown
```markdown
[![Sign this document](https://sign.company.com/badge/policy_2025.png)](https://sign.company.com/?doc=policy_2025)
```
### HTML
```html
<a href="https://sign.company.com/?doc=policy_2025">
<img src="https://sign.company.com/badge/policy_2025.png" alt="Signature status">
</a>
```
## oEmbed API
### Endpoint
```http
GET /oembed?url=https://sign.company.com/?doc=policy_2025
```
**Response**:
```json
{
"type": "rich",
"version": "1.0",
"title": "Document policy_2025 - 42 confirmations",
"provider_name": "Ackify",
"provider_url": "https://sign.company.com",
"html": "<iframe src=\"https://sign.company.com/?doc=policy_2025\" width=\"100%\" height=\"200\" frameborder=\"0\" style=\"border: 1px solid #ddd; border-radius: 6px;\" allowtransparency=\"true\"></iframe>",
"width": null,
"height": 200
}
```
### Parameters
| Parameter | Description | Example |
|-----------|-------------|---------|
| `url` | Document URL (required) | `?url=https://...` |
| `maxwidth` | Max width (optional) | `?maxwidth=800` |
| `maxheight` | Max height (optional) | `?maxheight=300` |
### Discovery
All pages include discovery tag:
```html
<link rel="alternate"
type="application/json+oembed"
href="https://sign.company.com/oembed?url=..."
title="Document title" />
```
## Customization
### Dark Mode Theme
Widget automatically detects browser's dark mode:
```css
@media (prefers-color-scheme: dark) {
/* Automatic dark theme */
}
```
### Custom Size
```html
<iframe src="https://sign.company.com/?doc=policy_2025"
width="800"
height="300"
frameborder="0">
</iframe>
```
### Language
Widget automatically detects browser language:
- `fr` - Français
- `en` - English
- `es` - Español
- `de` - Deutsch
- `it` - Italiano
## Security
### iFrame Sandboxing
By default, Ackify iframes allow:
- `allow-same-origin` - OAuth2 cookies
- `allow-scripts` - Vue.js features
- `allow-forms` - Signature submission
- `allow-popups` - OAuth redirect
### CORS
Ackify automatically configures CORS for:
- All origins (public reading)
- Credentials via `Access-Control-Allow-Credentials`
### CSP
Content Security Policy headers configured to allow embedding:
```
X-Frame-Options: SAMEORIGIN
Content-Security-Policy: frame-ancestors 'self' https://notion.so https://outline.com
```
## Troubleshooting
### iframe not displaying
Verify:
- HTTPS enabled (required for OAuth)
- CSP headers allow embedding
- No content blocker (uBlock, Privacy Badger)
### oEmbed not detected
Verify:
- Tag `<link rel="alternate" type="application/json+oembed">` is present
- URL is exact (with `?doc=...`)
- Platform supports oEmbed discovery
### Slack preview empty
Verify:
- Open Graph meta tags present
- URL publicly accessible
- No infinite redirect
## Complete Examples
See:
- [docs/integrations/google-doc/](../integrations/google-doc/) - Google Workspace integration
- More examples coming...

View File

@@ -0,0 +1,328 @@
# Expected Signers
Tracking expected signers with email reminders.
## Overview
The "Expected Signers" feature allows you to:
- Define who should sign a document
- Track completion rate
- Send automatic email reminders
- Detect unexpected signatures
## Adding Signers
### Via Admin Dashboard
1. Go to `/admin`
2. Select a document
3. Click "Expected Signers"
4. Paste email list:
```
Alice Smith <alice@company.com>
bob@company.com
charlie@company.com
```
**Supported formats**:
- One email per line
- Comma-separated emails
- Semicolon-separated emails
- Format with name: `Alice Smith <alice@company.com>`
### Via API
```http
POST /api/v1/admin/documents/policy_2025/signers
Content-Type: application/json
X-CSRF-Token: abc123
{
"email": "alice@company.com",
"name": "Alice Smith",
"notes": "Engineering team lead"
}
```
### Batch Adding
```bash
# Email list in a file
cat emails.txt | while read email; do
curl -X POST http://localhost:8080/api/v1/admin/documents/policy_2025/signers \
-b cookies.txt \
-H "X-CSRF-Token: $CSRF_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"email\": \"$email\"}"
done
```
## Completion Tracking
### Admin Dashboard
Displays:
- **Progress bar** - Visual with percentage
- **Signer list**:
- ✓ Email (signed on MM/DD/YYYY HH:MM)
- ⏳ Email (pending)
- **Statistics**:
- Expected: 50
- Signed: 42
- Pending: 8
- Completion: 84%
### Via API
```http
GET /api/v1/documents/policy_2025/expected-signers
```
**Response**:
```json
{
"docId": "policy_2025",
"expectedSigners": [
{
"email": "alice@company.com",
"name": "Alice Smith",
"addedAt": "2025-01-15T10:00:00Z",
"hasSigned": true,
"signedAt": "2025-01-15T14:30:00Z"
},
{
"email": "bob@company.com",
"name": "Bob Jones",
"addedAt": "2025-01-15T10:00:00Z",
"hasSigned": false
}
],
"completionStats": {
"expected": 50,
"signed": 42,
"pending": 8,
"completionPercentage": 84.0
}
}
```
## Email Reminders
### Sending Reminders
**Via Dashboard**:
1. Select recipients (or "Select all pending")
2. Choose language (fr, en, es, de, it)
3. Click "Send Reminders"
**Via API**:
```http
POST /api/v1/admin/documents/policy_2025/reminders
Content-Type: application/json
X-CSRF-Token: abc123
{
"emails": ["bob@company.com", "charlie@company.com"],
"locale": "fr"
}
```
**Response**:
```json
{
"sent": 2,
"failed": 0,
"errors": []
}
```
### Email Content
Templates are in `/backend/templates/emails/{locale}/reminder.html`:
```html
Hello {{.RecipientName}},
You are expected to sign the document "{{.DocumentTitle}}".
[Button: Sign now] → {{.SignURL}}
Document available here: {{.DocumentURL}}
Best regards,
{{.OrganisationName}}
```
**Available variables**:
- `RecipientName` - Recipient name
- `DocumentTitle` - Document title
- `DocumentURL` - Document URL (metadata)
- `SignURL` - Direct link to signature page
- `OrganisationName` - Your organization name
### Reminder History
```http
GET /api/v1/admin/documents/policy_2025/reminders
```
**Response**:
```json
{
"reminders": [
{
"recipientEmail": "bob@company.com",
"sentAt": "2025-01-15T15:00:00Z",
"sentBy": "admin@company.com",
"status": "sent",
"templateUsed": "reminder"
},
{
"recipientEmail": "charlie@company.com",
"sentAt": "2025-01-15T15:00:05Z",
"sentBy": "admin@company.com",
"status": "failed",
"errorMessage": "SMTP timeout"
}
]
}
```
**Statuses**:
- `sent` - Successfully sent
- `failed` - Send failure
- `bounced` - Invalid email (bounce)
## Unexpected Signatures
Automatically detects users who signed **without being expected**.
### Via Dashboard
"Unexpected Signatures" section displays:
```
⚠️ 3 unexpected signatures detected
- stranger@external.com (signed on 01/15/2025)
- unknown@gmail.com (signed on 01/16/2025)
```
### Via API
SQL query to detect:
```sql
SELECT s.user_email, s.signed_at
FROM signatures s
LEFT JOIN expected_signers e ON s.user_email = e.email AND s.doc_id = e.doc_id
WHERE s.doc_id = 'policy_2025' AND e.id IS NULL;
```
## Use Cases
### Mandatory Training
```
Document: "GDPR Training 2025"
Expected: All employees (CSV import)
```
**Workflow**:
1. Import CSV with employee emails
2. Send signature link to everyone
3. Automatic reminder on D+7 to non-signers
4. Final export for HR
### Security Policy
```
Document: "Security Policy v3"
Expected: Engineers + DevOps (50 people)
```
**Features used**:
- Real-time tracking (dashboard)
- Selective reminders (only some)
- Document metadata (URL + checksum)
### Contractual
```
Document: "NDA 2025"
Expected: External contractors (manual list)
```
**Particularity**:
- Restricted OAuth domain disabled
- Allows external emails to sign
- Unexpected signature detection crucial
## Removing a Signer
```http
DELETE /api/v1/admin/documents/policy_2025/signers/alice@company.com
X-CSRF-Token: abc123
```
**Behavior**:
- Removes from expected_signers list
- Signature (if exists) remains in database
- Completion rate is recalculated
## Email Configuration
For reminders to work, configure SMTP:
```bash
ACKIFY_MAIL_HOST=smtp.gmail.com
ACKIFY_MAIL_PORT=587
ACKIFY_MAIL_USERNAME=noreply@company.com
ACKIFY_MAIL_PASSWORD=app_password
ACKIFY_MAIL_FROM=noreply@company.com
```
See [Email Setup](../configuration/email-setup.md) for more details.
## Best Practices
### CSV Import
For bulk import:
```python
import csv
import requests
with open('employees.csv') as f:
reader = csv.DictReader(f)
for row in reader:
requests.post(
'http://localhost:8080/api/v1/admin/documents/policy_2025/signers',
json={'email': row['email'], 'name': row['name']},
headers={'X-CSRF-Token': csrf_token},
cookies=cookies
)
```
### Customization
For more personalized reminders:
1. Modify templates in `/backend/templates/emails/`
2. Add custom variables in email service
3. Rebuild Docker image
### Monitoring
Monitor `reminder_logs` to detect:
- High bounce rate (invalid emails)
- Repeated SMTP failures
- Reminder effectiveness (conversion rate)
## Limitations
- Maximum **1000 expected signers** per document (soft limit)
- Reminders sent **synchronously** (no queue)
- No automatic scheduled reminders (manual only)
## API Reference
See [API Documentation](../api.md#expected-signers-admin) for all endpoints.

320
docs/en/features/i18n.md Normal file
View File

@@ -0,0 +1,320 @@
# Internationalization (i18n)
Complete multilingual support for Ackify frontend.
## Supported Languages
- 🇫🇷 **Français** (default)
- 🇬🇧 **English** (fallback)
- 🇪🇸 **Español**
- 🇩🇪 **Deutsch**
- 🇮🇹 **Italiano**
## Frontend (Vue.js)
### Language Selection
The frontend automatically detects language via:
1. **localStorage** - Saved user choice
2. **navigator.language** - Browser language
3. **Fallback** - English if not supported
### Language Switcher
User interface with Unicode flags:
```
🇫🇷 FR | 🇬🇧 EN | 🇪🇸 ES | 🇩🇪 DE | 🇮🇹 IT
```
Click → Language changes + saves to localStorage
### Translation Files
Located in `/webapp/src/locales/`:
```
locales/
├── fr.json # Français
├── en.json # English
├── es.json # Español
├── de.json # Deutsch
└── it.json # Italiano
```
### JSON Structure
```json
{
"home": {
"title": "Ackify - Proof of Read",
"subtitle": "Cryptographic read signatures"
},
"document": {
"sign": "Sign this document",
"signed": "Document signed",
"signatures": "{count} confirmation | {count} confirmations"
}
}
```
**Pluralization**:
```json
"signatures": "{count} confirmation | {count} confirmations"
```
Usage:
```vue
{{ $t('document.signatures', { count: 42 }) }}
// → "42 confirmations"
```
## Backend (Go)
### Email Templates
Emails use multilingual templates in `/backend/templates/emails/`:
```
templates/emails/
├── fr/
│ ├── reminder.html
│ └── reminder.txt
├── en/
│ ├── reminder.html
│ └── reminder.txt
├── es/...
├── de/...
└── it/...
```
### Sending with Locale
```http
POST /api/v1/admin/documents/doc_id/reminders
Content-Type: application/json
{
"emails": ["user@company.com"],
"locale": "fr"
}
```
Backend loads template `fr/reminder.html`.
### Configuration
```bash
# Default language for emails (default: en)
ACKIFY_MAIL_DEFAULT_LOCALE=fr
```
## Adding a Language
### Frontend
1. **Create translation file**:
```bash
cd webapp/src/locales
cp en.json pt.json # Portuguese
```
2. **Translate**:
```json
{
"home": {
"title": "Ackify - Prova de Leitura",
"subtitle": "Assinaturas criptográficas de leitura"
}
}
```
3. **Register in i18n**:
```typescript
// webapp/src/i18n.ts
import pt from './locales/pt.json'
const i18n = createI18n({
locale: 'fr',
fallbackLocale: 'en',
messages: {
fr, en, es, de, it,
pt // Add here
}
})
```
4. **Add to selector**:
```vue
<!-- components/LanguageSwitcher.vue -->
<button @click="changeLocale('pt')">🇵🇹 PT</button>
```
### Backend
1. **Create directory**:
```bash
mkdir -p backend/templates/emails/pt
```
2. **Create templates**:
```bash
cp backend/templates/emails/en/reminder.html backend/templates/emails/pt/
cp backend/templates/emails/en/reminder.txt backend/templates/emails/pt/
```
3. **Translate templates**
4. **Rebuild**:
```bash
docker compose up -d --force-recreate ackify-ce --build
```
## i18n Verification
### Validation Script
Project includes a script to verify translation completeness:
```bash
cd webapp
npm run lint:i18n
```
**Output**:
```
✅ fr.json - 156 keys
✅ en.json - 156 keys
✅ es.json - 156 keys
✅ de.json - 156 keys
✅ it.json - 156 keys
All translations are complete!
```
### CI/CD
Script automatically runs in GitHub Actions to block PRs with missing translations.
## Best Practices
### Translation Keys
- ✅ Use structured keys: `feature.action.label`
- ✅ Group by page/component
- ✅ Avoid too generic keys (`button`, `title`)
- ✅ Use placeholders: `{count}`, `{name}`
**Example**:
```json
{
"admin": {
"documents": {
"list": {
"title": "Document list",
"count": "{count} document | {count} documents"
}
}
}
}
```
### Synchronization
When adding new keys in French:
```bash
# Sync script (to create)
node scripts/sync-i18n-from-fr.js
```
Automatically copies new keys to other languages with `[TODO]`.
### Long Texts
For long texts, use arrays:
```json
{
"help": {
"intro": [
"Ackify allows creating cryptographic signatures.",
"Each signature is timestamped and non-repudiable.",
"Data is stored immutably."
]
}
}
```
Usage:
```vue
<p v-for="line in $tm('help.intro')" :key="line">
{{ line }}
</p>
```
## Specific Formats
### Dates
```typescript
// Format with current locale
import { useI18n } from 'vue-i18n'
const { locale } = useI18n()
const formatted = new Date().toLocaleDateString(locale.value, {
year: 'numeric',
month: 'long',
day: 'numeric'
})
// fr: "15 janvier 2025"
// en: "January 15, 2025"
```
### Numbers
```typescript
const formatted = (42000).toLocaleString(locale.value)
// fr: "42 000"
// en: "42,000"
```
## SEO & Meta Tags
Meta tags are dynamically translated:
```vue
<script setup>
import { useI18n } from 'vue-i18n'
import { useHead } from '@vueuse/head'
const { t } = useI18n()
useHead({
title: t('home.title'),
meta: [
{ name: 'description', content: t('home.description') }
]
})
</script>
```
## Complete Documentation
For more details on frontend i18n implementation, see:
**[webapp/I18N.md](../../webapp/I18N.md)**
This file contains:
- Complete vue-i18n architecture
- Contribution guide
- Synchronization scripts
- Advanced examples

View File

@@ -0,0 +1,235 @@
# Cryptographic Signatures
Complete signature flow with Ed25519 and security guarantees.
## Principle
Ackify uses **Ed25519** (elliptic curve) to create non-repudiable cryptographic signatures.
**Guarantees**:
-**Non-repudiation** - The signature proves the signer's identity
-**Integrity** - SHA-256 hash detects any modification
-**Immutable timestamp** - PostgreSQL triggers prevent backdating
-**Uniqueness** - One signature per user/document
## Signature Flow
### 1. User accesses the document
```
https://sign.company.com/?doc=policy_2025
```
The Vue.js frontend loads and displays:
- Document title (if metadata exists)
- Number of existing signatures
- "Sign this document" button
### 2. Session verification
The frontend calls:
```http
GET /api/v1/users/me
```
**If not logged in** → OAuth2 redirect
**If logged in** → Display signature button
### 3. Signature
When clicking "Sign", the frontend:
1. Gets a CSRF token:
```http
GET /api/v1/csrf
```
2. Sends the signature:
```http
POST /api/v1/signatures
Content-Type: application/json
X-CSRF-Token: abc123
{
"doc_id": "policy_2025"
}
```
### 4. Backend Processing
The backend (Go):
1. **Verifies the session** - User authenticated
2. **Generates Ed25519 signature**:
```go
payload := fmt.Sprintf("%s:%s:%s:%s", docID, userSub, userEmail, timestamp)
hash := sha256.Sum256([]byte(payload))
signature := ed25519.Sign(privateKey, hash[:])
```
3. **Calculates prev_hash** - Hash of the last signature (chaining)
4. **Inserts into database**:
```sql
INSERT INTO signatures (doc_id, user_sub, user_email, signed_at, payload_hash, signature, nonce, prev_hash)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
```
5. **Returns the signature** to the frontend
### 5. Confirmation
The frontend displays:
- ✅ Signature confirmed
- Timestamp
- Link to signatures list
## Signature Structure
```json
{
"docId": "policy_2025",
"userEmail": "alice@company.com",
"userName": "Alice Smith",
"signedAt": "2025-01-15T14:30:00Z",
"payloadHash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"signature": "ed25519:3045022100...",
"nonce": "abc123xyz",
"prevHash": "sha256:prev..."
}
```
**Fields**:
- `payloadHash` - SHA-256 of the payload (doc_id:user_sub:email:timestamp)
- `signature` - Ed25519 signature in base64
- `nonce` - Anti-replay protection
- `prevHash` - Hash of the previous signature (blockchain-like)
## Signature Verification
### Manual (via API)
```http
GET /api/v1/documents/policy_2025/signatures
```
Returns all signatures with:
- Signer email
- Timestamp
- Hash + signature
### Programmatic (Go)
```go
import "crypto/ed25519"
func VerifySignature(publicKey ed25519.PublicKey, payload, signature []byte) bool {
hash := sha256.Sum256(payload)
return ed25519.Verify(publicKey, hash[:], signature)
}
```
## PostgreSQL Constraints
### One signature per user/document
```sql
UNIQUE (doc_id, user_sub)
```
**Behavior**:
- If the user tries to sign twice → 409 Conflict error
- The frontend detects this and displays "Already signed"
### Immutability of `created_at`
PostgreSQL trigger:
```sql
CREATE TRIGGER prevent_signatures_created_at_update
BEFORE UPDATE ON signatures
FOR EACH ROW
EXECUTE FUNCTION prevent_created_at_update();
```
**Guarantee**: Impossible to backdate a signature.
## Chaining (Blockchain-like)
Each signature references the previous one via `prev_hash`:
```
Signature 1 → hash1
Signature 2 → hash2 (prev_hash = hash1)
Signature 3 → hash3 (prev_hash = hash2)
```
**Tampering detection**:
- If a signature is modified, the `prev_hash` of the next one no longer matches
- Allows detection of any history modification
## Security
### Ed25519 Private Key
Auto-generated on first startup or via:
```bash
ACKIFY_ED25519_PRIVATE_KEY=$(openssl rand -base64 64)
```
**Important**:
- The private key never leaves the server
- Stored in memory only (not in database)
- Backup required if you want to keep the same key after redeployment
### Anti-Replay Protection
The unique `nonce` prevents signature reuse:
```go
nonce := fmt.Sprintf("%s-%d", userSub, time.Now().UnixNano())
```
### Rate Limiting
Signatures are limited to **100 requests/minute** per IP.
## Use Cases
### Policy Read Validation
```
Document: "Security Policy 2025"
URL: https://sign.company.com/?doc=security_policy_2025
```
**Workflow**:
1. Admin sends the link to employees
2. Each employee clicks, reads, and signs
3. Admin sees completion in `/admin`
### Training Acknowledgment
```
Document: "GDPR Training 2025"
Expected signers: 50 employees
```
**Features**:
- Completion tracking (42/50 = 84%)
- Automatic email reminders
- Signature export
### Contractual Acknowledgment
```
Document: "Terms of Service v3"
Checksum: SHA-256 of the PDF
```
**Verification**:
- User calculates the PDF checksum
- Compares with stored metadata
- Signs if identical
See [Checksums](checksums.md) for more details.
## API Reference
See [API Documentation](../api.md) for all signature-related endpoints.

208
docs/en/getting-started.md Normal file
View File

@@ -0,0 +1,208 @@
# Getting Started
Installation and configuration guide for Ackify with Docker Compose.
## Prerequisites
- Docker and Docker Compose installed
- A domain (or localhost for testing)
- OAuth2 credentials (Google, GitHub, GitLab, or custom)
## Quick Installation
### 1. Clone the repository
```bash
git clone https://github.com/btouchard/ackify-ce.git
cd ackify-ce
```
### 2. Configuration
Copy the example file and edit it:
```bash
cp .env.example .env
nano .env
```
**Minimum required variables**:
```bash
# Public domain of your instance
APP_DNS=sign.your-domain.com
ACKIFY_BASE_URL=https://sign.your-domain.com
ACKIFY_ORGANISATION="Your Organization Name"
# PostgreSQL database
POSTGRES_USER=ackifyr
POSTGRES_PASSWORD=your_secure_password_here
POSTGRES_DB=ackify
# OAuth2 (example with Google)
ACKIFY_OAUTH_PROVIDER=google
ACKIFY_OAUTH_CLIENT_ID=your_google_client_id
ACKIFY_OAUTH_CLIENT_SECRET=your_google_client_secret
# Security - generate with: openssl rand -base64 32
ACKIFY_OAUTH_COOKIE_SECRET=your_base64_encoded_secret_key
```
### 3. Start
```bash
docker compose up -d
```
This command will:
- Download necessary Docker images
- Start PostgreSQL with healthcheck
- Apply database migrations
- Launch the Ackify application
### 4. Verification
```bash
# View logs
docker compose logs -f ackify-ce
# Check health endpoint
curl http://localhost:8080/api/v1/health
# Expected: {"status":"healthy","database":"connected"}
```
### 5. Access the interface
Open your browser:
- **Public interface**: http://localhost:8080
- **Admin dashboard**: http://localhost:8080/admin (requires email in ACKIFY_ADMIN_EMAILS)
## OAuth2 Configuration
Before using Ackify, configure your OAuth2 provider.
### Google OAuth2
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select an existing one
3. Enable the "Google+ API"
4. Create OAuth 2.0 credentials:
- Type: Web application
- Authorized redirect URIs: `https://sign.your-domain.com/api/v1/auth/callback`
5. Copy the Client ID and Client Secret to `.env`
```bash
ACKIFY_OAUTH_PROVIDER=google
ACKIFY_OAUTH_CLIENT_ID=123456789-abc.apps.googleusercontent.com
ACKIFY_OAUTH_CLIENT_SECRET=GOCSPX-xyz...
```
### GitHub OAuth2
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
2. Create a new OAuth App
3. Configuration:
- Homepage URL: `https://sign.your-domain.com`
- Callback URL: `https://sign.your-domain.com/api/v1/auth/callback`
4. Generate a client secret
```bash
ACKIFY_OAUTH_PROVIDER=github
ACKIFY_OAUTH_CLIENT_ID=Iv1.abc123
ACKIFY_OAUTH_CLIENT_SECRET=ghp_xyz...
```
See [OAuth Providers](configuration/oauth-providers.md) for GitLab and custom providers.
## Generating Secrets
```bash
# Cookie secret (required)
openssl rand -base64 32
# Ed25519 private key (optional, auto-generated if missing)
openssl rand -base64 64
```
## First Steps
### Create your first signature
1. Go to `http://localhost:8080/?doc=test_document`
2. Click "Sign this document"
3. Login via OAuth2
4. Validate the signature
### Access the admin dashboard
1. Add your email in `.env`:
```bash
ACKIFY_ADMIN_EMAILS=admin@company.com
```
2. Restart:
```bash
docker compose restart ackify-ce
```
3. Login and access `/admin`
### Embed in a page
```html
<!-- Embeddable widget -->
<iframe src="https://sign.your-domain.com/?doc=test_document"
width="600" height="200"
frameborder="0"
style="border: 1px solid #ddd; border-radius: 6px;"></iframe>
```
## Useful Commands
```bash
# View logs
docker compose logs -f ackify-ce
# Restart
docker compose restart ackify-ce
# Stop
docker compose down
# Rebuild after changes
docker compose up -d --force-recreate ackify-ce --build
# Access the database
docker compose exec ackify-db psql -U ackifyr -d ackify
```
## Troubleshooting
### Application doesn't start
```bash
# Check logs
docker compose logs ackify-ce
# Check PostgreSQL health
docker compose ps ackify-db
```
### Migration error
```bash
# Manually re-run migrations
docker compose up ackify-migrate
```
### OAuth callback error
Verify that:
- `ACKIFY_BASE_URL` exactly matches your domain
- The callback URL in the OAuth2 provider is correct
- The cookie secret is properly configured
## Next Steps
- [Complete configuration](configuration.md)
- [Production deployment](deployment.md)
- [Features configuration](features/)
- [API Reference](api.md)

39
docs/fr/README.md Normal file
View File

@@ -0,0 +1,39 @@
# Ackify Documentation
Documentation complète pour Ackify - Proof of Read avec signatures cryptographiques.
## Démarrage Rapide
- **[Getting Started](getting-started.md)** - Installation et premiers pas avec Docker Compose
- **[Configuration](configuration.md)** - Variables d'environnement et paramétrage
## Fonctionnalités
- **[Signatures Cryptographiques](features/signatures.md)** - Flow de signature Ed25519
- **[Expected Signers](features/expected-signers.md)** - Tracking et rappels email
- **[Checksums](features/checksums.md)** - Vérification d'intégrité des documents
- **[Embedding](features/embedding.md)** - oEmbed, iframes, intégrations tierces
- **[Internationalisation](features/i18n.md)** - Support multilingue (fr, en, es, de, it)
## Configuration Avancée
- **[OAuth Providers](configuration/oauth-providers.md)** - Google, GitHub, GitLab, Custom
- **[Email Setup](configuration/email-setup.md)** - Configuration SMTP pour les rappels
## Architecture & Développement
- **[Architecture](architecture.md)** - Stack technique, structure projet, principes Clean Architecture
- **[Database](database.md)** - Schéma PostgreSQL, migrations, contraintes
- **[API Reference](api.md)** - Endpoints REST, exemples, OpenAPI
- **[Deployment](deployment.md)** - Production, sécurité, monitoring
- **[Development](development.md)** - Setup développement, tests, contribution
## Intégrations
- **[Google Docs](integrations/google-doc/)** - Intégration avec Google Workspace
- Plus d'intégrations à venir...
## Support
- [GitHub Issues](https://github.com/btouchard/ackify-ce/issues) - Bugs et demandes de fonctionnalités
- [GitHub Discussions](https://github.com/btouchard/ackify-ce/discussions) - Questions et discussions

355
docs/fr/architecture.md Normal file
View File

@@ -0,0 +1,355 @@
# Architecture
Stack technique et principes de conception d'Ackify.
## Vue d'Ensemble
Ackify est une **application monolithique moderne** avec séparation claire backend/frontend.
```
┌─────────────────────────────────────────┐
│ Client Browser │
│ (Vue.js 3 SPA + TypeScript) │
└──────────────┬──────────────────────────┘
│ HTTPS / JSON
┌──────────────▼──────────────────────────┐
│ Go Backend (API-first) │
│ ├─ RESTful API v1 (chi router) │
│ ├─ OAuth2 Service │
│ ├─ Ed25519 Crypto │
│ └─ SMTP Email (optionnel) │
└──────────────┬──────────────────────────┘
│ PostgreSQL protocol
┌──────────────▼──────────────────────────┐
│ PostgreSQL 16 Database │
│ (Signatures + Metadata + Sessions) │
└─────────────────────────────────────────┘
```
## Backend (Go)
### Clean Architecture Simplifiée
```
backend/
├── cmd/
│ ├── community/ # Point d'entrée + injection dépendances
│ └── migrate/ # Outil migrations SQL
├── internal/
│ ├── domain/
│ │ └── models/ # Entités métier (User, Signature, Document)
│ ├── application/
│ │ └── services/ # Logique métier (SignatureService, etc.)
│ ├── infrastructure/
│ │ ├── auth/ # OAuth2 service
│ │ ├── database/ # Repositories PostgreSQL
│ │ ├── email/ # SMTP service
│ │ ├── config/ # Variables d'environnement
│ │ └── i18n/ # Backend i18n
│ └── presentation/
│ ├── api/ # HTTP handlers API v1
│ └── handlers/ # Legacy OAuth handlers
├── pkg/
│ ├── crypto/ # Ed25519 signatures
│ ├── logger/ # Structured logging
│ ├── services/ # OAuth provider detection
│ └── web/ # HTTP server setup
├── migrations/ # SQL migrations
├── templates/ # Email templates (HTML/text)
└── locales/ # Backend translations
```
### Principes Go Appliqués
**Interfaces** :
- ✅ Définies dans le package qui les utilise
- ✅ Principe "accept interfaces, return structs"
- ✅ Repositories implémentés dans `infrastructure/database/`
**Injection de Dépendances** :
- ✅ Constructeurs explicites dans `main.go`
- ✅ Pas de container DI complexe
- ✅ Dépendances claires et visibles
**Code Quality** :
-`go fmt` et `go vet` clean
- ✅ Pas de code mort
- ✅ Interfaces simples et focalisées
## Frontend (Vue.js 3)
### Structure SPA
```
webapp/
├── src/
│ ├── components/ # Composants réutilisables
│ │ ├── ui/ # shadcn/vue components
│ │ └── ...
│ ├── pages/ # Pages (router views)
│ │ ├── Home.vue
│ │ ├── Admin.vue
│ │ └── ...
│ ├── services/ # API client (axios)
│ ├── stores/ # Pinia state management
│ ├── router/ # Vue Router config
│ ├── locales/ # Traductions (fr, en, es, de, it)
│ └── composables/ # Vue composables
├── public/ # Assets statiques
└── scripts/ # Build scripts
```
### Stack Frontend
- **Vue 3** - Composition API
- **TypeScript** - Type safety
- **Vite** - Build tool (HMR rapide)
- **Pinia** - State management
- **Vue Router** - Client routing
- **Tailwind CSS** - Utility-first styling
- **shadcn/vue** - UI components
- **vue-i18n** - Internationalisation
### Routing
```typescript
const routes = [
{ path: '/', component: Home }, // Public
{ path: '/signatures', component: MySignatures }, // Auth required
{ path: '/admin', component: Admin } // Admin only
]
```
Le frontend gère :
- Route `/` avec query param `?doc=xxx` → Page de signature
- Route `/admin` → Dashboard admin
- Route `/signatures` → Mes signatures
## Base de Données
### Schéma PostgreSQL
Tables principales :
- `signatures` - Signatures Ed25519
- `documents` - Métadonnées documents
- `expected_signers` - Tracking signataires
- `reminder_logs` - Historique emails
- `checksum_verifications` - Vérifications intégrité
- `oauth_sessions` - Sessions OAuth2 + refresh tokens
Voir [Database](database.md) pour le schéma complet.
### Migrations
- Format : `XXXX_description.up.sql` / `XXXX_description.down.sql`
- Appliquées automatiquement au démarrage (service `ackify-migrate`)
- Outil : `/backend/cmd/migrate`
## Sécurité
### Cryptographie
**Ed25519** :
- Signatures digitales (courbe elliptique)
- Clé privée 256 bits
- Non-répudiation garantie
**SHA-256** :
- Hachage payload avant signature
- Détection de tampering
- Chaînage blockchain-like (`prev_hash`)
**AES-256-GCM** :
- Chiffrement refresh tokens OAuth2
- Clé dérivée de `ACKIFY_OAUTH_COOKIE_SECRET`
### OAuth2 + PKCE
**Flow** :
1. Client génère `code_verifier` (random)
2. Calcule `code_challenge = SHA256(code_verifier)`
3. Auth request avec `code_challenge`
4. Provider retourne `code`
5. Token exchange avec `code + code_verifier`
**Sécurité** :
- Protection contre interception du code
- Méthode S256 (SHA-256)
- Activé automatiquement
### Sessions
- Cookies sécurisés (HttpOnly, Secure, SameSite=Lax)
- Chiffrement HMAC-SHA256
- Stockage PostgreSQL avec refresh tokens chiffrés
- Durée : 30 jours
- Cleanup automatique : 37 jours
## Build & Déploiement
### Multi-Stage Docker
```dockerfile
# Stage 1 - Frontend build
FROM node:22-alpine AS frontend
COPY webapp/ /build/webapp/
RUN npm ci && npm run build
# Output: webapp/dist/
# Stage 2 - Backend build + embed frontend
FROM golang:alpine AS backend
ENV GOTOOLCHAIN=auto
COPY backend/ /build/backend/
COPY --from=frontend /build/webapp/dist/ /build/backend/cmd/community/web/dist/
RUN go build -o community ./cmd/community
# Frontend embedded via embed.FS
# Stage 3 - Runtime (distroless)
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=backend /build/backend/community /app/community
CMD ["/app/community"]
```
**Résultat** :
- Image finale < 30 MB
- Binaire unique (backend + frontend)
- Aucune dépendance runtime
### Injection Runtime
Le `ACKIFY_BASE_URL` est injecté dans `index.html` au démarrage :
```go
// Remplace __ACKIFY_BASE_URL__ par la valeur réelle
html = strings.ReplaceAll(html, "__ACKIFY_BASE_URL__", baseURL)
```
Permet de changer le domaine sans rebuild.
## Performance
### Backend
- **Connection pooling** PostgreSQL (25 max)
- **Prepared statements** - Anti-injection SQL
- **Rate limiting** - 5 auth/min, 10 doc/min, 100 req/min
- **Structured logging** - JSON avec request IDs
### Frontend
- **Code splitting** - Lazy loading routes
- **Tree shaking** - Dead code elimination
- **Minification** - Production builds optimisés
- **HMR** - Hot Module Replacement (dev)
### Database
- **Index** sur (doc_id, user_sub, session_id)
- **Constraints** UNIQUE pour garanties
- **Triggers** pour immutabilité
- **Autovacuum** activé
## Scalabilité
### Limites Actuelles
- ✅ Monolithe : ~10k req/s
- ✅ PostgreSQL : Single instance
- ✅ Sessions : In-database (pas de Redis)
### Scaling Horizontal (futur)
Pour > 100k req/s :
1. **Load Balancer** - Multiple instances backend
2. **PostgreSQL read replicas** - Séparation read/write
3. **Redis** - Cache sessions + rate limiting
4. **CDN** - Assets statiques
## Monitoring
### Logs Structurés
Format JSON :
```json
{
"level": "info",
"timestamp": "2025-01-15T14:30:00Z",
"request_id": "abc123",
"method": "POST",
"path": "/api/v1/signatures",
"duration_ms": 42,
"status": 201
}
```
### Health Check
```http
GET /api/v1/health
```
Response :
```json
{
"status": "healthy",
"database": "connected"
}
```
### Métriques (futur)
- Prometheus metrics endpoint
- Grafana dashboards
- Alerting (PagerDuty, Slack)
## Tests
### Coverage
**72.6% code coverage** (unit + integration)
- Unit tests : 180+ tests
- Integration tests : 33 tests PostgreSQL
- CI/CD : GitHub Actions + Codecov
Voir [Development](development.md) pour lancer les tests.
## Choix Techniques
### Pourquoi Go ?
- ✅ Performance native (compiled)
- ✅ Concurrency simple (goroutines)
- ✅ Typage fort
- ✅ Binaire unique
- ✅ Déploiement simple
### Pourquoi Vue 3 ?
- ✅ Composition API moderne
- ✅ TypeScript natif
- ✅ Reactive par défaut
- ✅ Ecosystème riche
- ✅ Performances excellentes
### Pourquoi PostgreSQL ?
- ✅ ACID compliance
- ✅ Contraintes d'intégrité
- ✅ Triggers
- ✅ JSON support
- ✅ Mature et stable
### Pourquoi Ed25519 ?
- ✅ Sécurité moderne (courbe elliptique)
- ✅ Performance > RSA
- ✅ Signatures courtes (64 bytes)
- ✅ Standard crypto/ed25519 Go
## Références
- [Go Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
- [Vue 3 Composition API](https://vuejs.org/guide/extras/composition-api-faq.html)
- [Ed25519 Spec](https://ed25519.cr.yp.to/)
- [OAuth2 + PKCE](https://oauth.net/2/pkce/)

184
docs/fr/configuration.md Normal file
View File

@@ -0,0 +1,184 @@
# Configuration
Guide complet de configuration d'Ackify via variables d'environnement.
## Variables Obligatoires
Ces variables sont **requises** pour démarrer Ackify :
```bash
# URL publique de votre instance (utilisée pour OAuth callbacks)
APP_DNS=sign.your-domain.com
ACKIFY_BASE_URL=https://sign.your-domain.com
# Nom de votre organisation (affiché dans l'interface)
ACKIFY_ORGANISATION="Your Organization Name"
# Configuration PostgreSQL
POSTGRES_USER=ackifyr
POSTGRES_PASSWORD=your_secure_password
POSTGRES_DB=ackify
# OAuth2 Provider
ACKIFY_OAUTH_PROVIDER=google # ou github, gitlab, ou vide pour custom
ACKIFY_OAUTH_CLIENT_ID=your_oauth_client_id
ACKIFY_OAUTH_CLIENT_SECRET=your_oauth_client_secret
# Secret pour chiffrer les cookies de session (générer avec: openssl rand -base64 32)
ACKIFY_OAUTH_COOKIE_SECRET=your_base64_encoded_secret_key
```
## Variables Optionnelles
### Serveur
```bash
# Adresse d'écoute HTTP (défaut: :8080)
ACKIFY_LISTEN_ADDR=:8080
# Niveau de logs: debug, info, warn, error (défaut: info)
ACKIFY_LOG_LEVEL=info
```
### Sécurité & OAuth2
```bash
# Restreindre l'accès à un domaine email spécifique
ACKIFY_OAUTH_ALLOWED_DOMAIN=@company.com
# Activer l'auto-login silencieux (défaut: false)
ACKIFY_OAUTH_AUTO_LOGIN=false
# URL de logout personnalisée (optionnel)
ACKIFY_OAUTH_LOGOUT_URL=https://your-provider.com/logout
# Scopes OAuth2 personnalisés (défaut: openid,email,profile)
ACKIFY_OAUTH_SCOPES=openid,email,profile
```
### Administration
```bash
# Liste d'emails admin (séparés par virgules)
ACKIFY_ADMIN_EMAILS=admin@company.com,admin2@company.com
```
Les admins ont accès à :
- Dashboard admin (`/admin`)
- Gestion des métadonnées documents
- Tracking des signataires attendus
- Envoi de rappels email
- Suppression de documents
### Checksums Documents (Optionnel)
Configuration pour le calcul automatique de checksum lors de la création de documents depuis des URLs :
```bash
# Taille maximale de fichier à télécharger pour le calcul de checksum (défaut: 10485760 = 10MB)
ACKIFY_CHECKSUM_MAX_BYTES=10485760
# Timeout pour le téléchargement checksum en millisecondes (défaut: 5000ms = 5s)
ACKIFY_CHECKSUM_TIMEOUT_MS=5000
# Nombre maximum de redirections HTTP à suivre (défaut: 3)
ACKIFY_CHECKSUM_MAX_REDIRECTS=3
# Liste de types MIME autorisés séparés par virgules (défaut inclut PDF, images, docs Office, ODF)
ACKIFY_CHECKSUM_ALLOWED_TYPES=application/pdf,image/*,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.*
```
**Note** : Ces paramètres s'appliquent uniquement lorsque les admins créent des documents via le dashboard admin avec une URL distante. Le système tentera de télécharger et calculer le checksum SHA-256 automatiquement.
## Configuration Avancée
### OAuth2 Providers
Voir [OAuth Providers](configuration/oauth-providers.md) pour la configuration détaillée de :
- Google OAuth2
- GitHub OAuth2
- GitLab OAuth2 (public + self-hosted)
- Custom OAuth2 provider
### Email (SMTP)
Voir [Email Setup](configuration/email-setup.md) pour configurer l'envoi de rappels email.
## Exemple Complet
Exemple de `.env` pour une installation en production :
```bash
# Application
APP_DNS=sign.company.com
ACKIFY_BASE_URL=https://sign.company.com
ACKIFY_ORGANISATION="ACME Corporation"
ACKIFY_LOG_LEVEL=info
ACKIFY_LISTEN_ADDR=:8080
# Base de données
POSTGRES_USER=ackifyr
POSTGRES_PASSWORD=super_secure_password_123
POSTGRES_DB=ackify
# OAuth2 (Google)
ACKIFY_OAUTH_PROVIDER=google
ACKIFY_OAUTH_CLIENT_ID=123456789-abc.apps.googleusercontent.com
ACKIFY_OAUTH_CLIENT_SECRET=GOCSPX-xyz123
ACKIFY_OAUTH_ALLOWED_DOMAIN=@company.com
# Sécurité
ACKIFY_OAUTH_COOKIE_SECRET=ZXhhbXBsZV9iYXNlNjRfc2VjcmV0X2tleQ==
# Administration
ACKIFY_ADMIN_EMAILS=admin@company.com,cto@company.com
# Email (optionnel - omettre MAIL_HOST pour désactiver)
ACKIFY_MAIL_HOST=smtp.gmail.com
ACKIFY_MAIL_PORT=587
ACKIFY_MAIL_USERNAME=noreply@company.com
ACKIFY_MAIL_PASSWORD=app_specific_password
ACKIFY_MAIL_FROM=noreply@company.com
ACKIFY_MAIL_FROM_NAME="Ackify - ACME"
ACKIFY_MAIL_TEMPLATE_DIR=templates/emails
ACKIFY_MAIL_DEFAULT_LOCALE=fr
# Checksums Documents (optionnel - pour auto-checksum depuis URLs)
ACKIFY_CHECKSUM_MAX_BYTES=10485760
ACKIFY_CHECKSUM_TIMEOUT_MS=5000
ACKIFY_CHECKSUM_MAX_REDIRECTS=3
```
## Validation de la Configuration
Après modification du `.env`, redémarrer :
```bash
docker compose restart ackify-ce
```
Vérifier les logs :
```bash
docker compose logs -f ackify-ce
```
Tester le health check :
```bash
curl http://localhost:8080/api/v1/health
```
## Variables de Production
**Checklist sécurité production** :
- ✅ Utiliser HTTPS (`ACKIFY_BASE_URL=https://...`)
- ✅ Générer des secrets forts (64+ caractères)
- ✅ Restreindre le domaine OAuth (`ACKIFY_OAUTH_ALLOWED_DOMAIN`)
- ✅ Configurer les emails admin (`ACKIFY_ADMIN_EMAILS`)
- ✅ Utiliser PostgreSQL avec SSL en production
- ✅ Logger en mode `info` (pas `debug`)
- ✅ Sauvegarder régulièrement la base de données
Voir [Deployment](deployment.md) pour plus de détails sur le déploiement en production.

View File

@@ -0,0 +1,353 @@
# Email Setup
Configuration SMTP pour l'envoi de rappels email aux signataires attendus.
## Vue d'Ensemble
Le service email d'Ackify permet d'envoyer des rappels automatiques aux utilisateurs qui n'ont pas encore signé un document.
**Fonctionnalités** :
- Envoi de rappels multilingues (fr, en, es, de, it)
- Templates HTML et texte brut
- Historique des envois dans PostgreSQL
- Support TLS/STARTTLS
- Timeout configurable
**Note** : Le service email est **optionnel**. Si `ACKIFY_MAIL_HOST` n'est pas défini, les emails sont désactivés.
## Configuration de Base
### Variables Obligatoires
```bash
# Serveur SMTP
ACKIFY_MAIL_HOST=smtp.gmail.com
ACKIFY_MAIL_PORT=587
ACKIFY_MAIL_USERNAME=your-email@gmail.com
ACKIFY_MAIL_PASSWORD=your-app-password
# Adresse expéditeur
ACKIFY_MAIL_FROM=noreply@company.com
```
### Variables Optionnelles
```bash
# Nom affiché de l'expéditeur (défaut: ACKIFY_ORGANISATION)
ACKIFY_MAIL_FROM_NAME="Ackify - ACME Corporation"
# Préfixe pour le sujet des emails (optionnel)
ACKIFY_MAIL_SUBJECT_PREFIX="[Ackify]"
# Activer TLS (défaut: true)
ACKIFY_MAIL_TLS=true
# Activer STARTTLS (défaut: true)
ACKIFY_MAIL_STARTTLS=true
# Timeout de connexion (défaut: 10s)
ACKIFY_MAIL_TIMEOUT=10s
# Répertoire des templates email (défaut: templates/emails)
ACKIFY_MAIL_TEMPLATE_DIR=templates/emails
# Langue par défaut pour les emails (défaut: en)
# Langues supportées : en, fr, es, de, it
ACKIFY_MAIL_DEFAULT_LOCALE=fr
```
## Providers SMTP Populaires
### Gmail
**Configuration** :
```bash
ACKIFY_MAIL_HOST=smtp.gmail.com
ACKIFY_MAIL_PORT=587
ACKIFY_MAIL_USERNAME=your-email@gmail.com
ACKIFY_MAIL_PASSWORD=your-app-password
ACKIFY_MAIL_TLS=true
ACKIFY_MAIL_STARTTLS=true
```
**Prérequis** :
1. Activer la validation en 2 étapes sur votre compte Google
2. Générer un "App Password" : https://myaccount.google.com/apppasswords
3. Utiliser ce mot de passe dans `ACKIFY_MAIL_PASSWORD`
### SendGrid
```bash
ACKIFY_MAIL_HOST=smtp.sendgrid.net
ACKIFY_MAIL_PORT=587
ACKIFY_MAIL_USERNAME=apikey
ACKIFY_MAIL_PASSWORD=your-sendgrid-api-key
ACKIFY_MAIL_FROM=noreply@your-domain.com
ACKIFY_MAIL_TLS=true
```
### Amazon SES
```bash
ACKIFY_MAIL_HOST=email-smtp.us-east-1.amazonaws.com
ACKIFY_MAIL_PORT=587
ACKIFY_MAIL_USERNAME=your-smtp-username
ACKIFY_MAIL_PASSWORD=your-smtp-password
ACKIFY_MAIL_FROM=noreply@verified-domain.com
ACKIFY_MAIL_TLS=true
```
**Important** : Vérifier votre domaine dans AWS SES avant d'envoyer.
### Mailgun
```bash
ACKIFY_MAIL_HOST=smtp.mailgun.org
ACKIFY_MAIL_PORT=587
ACKIFY_MAIL_USERNAME=postmaster@your-domain.mailgun.org
ACKIFY_MAIL_PASSWORD=your-mailgun-smtp-password
ACKIFY_MAIL_FROM=noreply@your-domain.com
ACKIFY_MAIL_TLS=true
```
### SMTP Custom (Self-hosted)
```bash
ACKIFY_MAIL_HOST=mail.company.com
ACKIFY_MAIL_PORT=587
ACKIFY_MAIL_USERNAME=ackify@company.com
ACKIFY_MAIL_PASSWORD=secure_password
ACKIFY_MAIL_FROM=ackify@company.com
ACKIFY_MAIL_TLS=true
ACKIFY_MAIL_STARTTLS=true
```
## Templates Email
Les templates sont dans `/backend/templates/emails/` avec support multilingue.
### Structure
```
templates/emails/
├── fr/
│ ├── reminder.html # Template HTML français
│ └── reminder.txt # Template texte brut français
├── en/
│ ├── reminder.html # Template HTML anglais
│ └── reminder.txt # Template texte brut anglais
└── ...
```
### Variables Disponibles
Dans les templates, vous pouvez utiliser :
```go
{{.RecipientName}} // Nom du destinataire
{{.DocumentID}} // ID du document
{{.DocumentTitle}} // Titre du document
{{.DocumentURL}} // URL du document (si définie dans metadata)
{{.SignURL}} // URL pour signer
{{.OrganisationName}} // Nom de l'organisation
{{.SenderName}} // Nom de l'expéditeur (admin)
```
### Exemple de Template HTML
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Rappel de signature</title>
</head>
<body>
<h1>Bonjour {{.RecipientName}},</h1>
<p>
Vous êtes attendu(e) pour signer le document
<strong>{{.DocumentTitle}}</strong>.
</p>
<p>
<a href="{{.SignURL}}" style="background: #0066cc; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
Signer maintenant
</a>
</p>
<p>
Cordialement,<br>
{{.OrganisationName}}
</p>
</body>
</html>
```
### Personnaliser les Templates
Pour utiliser des templates personnalisés :
```bash
ACKIFY_MAIL_TEMPLATE_DIR=/custom/path/to/email/templates
```
Assurez-vous de conserver la même structure de répertoires (langue/reminder.html).
## Envoi de Rappels
### Via le Dashboard Admin
1. Aller sur `/admin`
2. Sélectionner un document
3. Cliquer sur "Expected Signers"
4. Sélectionner les destinataires
5. Cliquer sur "Send Reminders"
### Via l'API
```bash
curl -X POST http://localhost:8080/api/v1/admin/documents/doc_id/reminders \
-b cookies.txt \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: YOUR_TOKEN" \
-d '{
"emails": ["user1@company.com", "user2@company.com"],
"locale": "fr"
}'
```
**Réponse** :
```json
{
"sent": 2,
"failed": 0,
"errors": []
}
```
## Historique des Rappels
Les envois sont tracés dans la table `reminder_logs` :
```sql
SELECT
recipient_email,
sent_at,
status,
error_message
FROM reminder_logs
WHERE doc_id = 'my_document'
ORDER BY sent_at DESC;
```
**Statuts possibles** :
- `sent` - Envoyé avec succès
- `failed` - Échec d'envoi
- `bounced` - Rebond (email invalide)
## Tester la Configuration
### Test Manuel via API
```bash
# 1. Se connecter en tant qu'admin
# 2. Ajouter un expected signer avec votre email
curl -X POST http://localhost:8080/api/v1/admin/documents/test_doc/signers \
-b cookies.txt \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: YOUR_TOKEN" \
-d '{
"email": "your-email@company.com",
"name": "Test User"
}'
# 3. Envoyer un rappel test
curl -X POST http://localhost:8080/api/v1/admin/documents/test_doc/reminders \
-b cookies.txt \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: YOUR_TOKEN" \
-d '{
"emails": ["your-email@company.com"],
"locale": "en"
}'
```
### Vérifier les Logs
```bash
docker compose logs -f ackify-ce | grep -i mail
```
Vous devriez voir :
```
INFO Email sent successfully to: your-email@company.com
```
## Troubleshooting
### Erreur "SMTP connection failed"
Vérifier :
- `ACKIFY_MAIL_HOST` et `ACKIFY_MAIL_PORT` sont corrects
- Votre serveur autorise les connexions sortantes sur le port SMTP
- `ACKIFY_MAIL_TLS=true` si le serveur requiert TLS
### Erreur "Authentication failed"
Vérifier :
- `ACKIFY_MAIL_USERNAME` et `ACKIFY_MAIL_PASSWORD` sont corrects
- Pour Gmail : utiliser un "App Password", pas votre mot de passe principal
- Pour SendGrid : le username doit être `apikey`
### Email non reçu mais status "sent"
Vérifier :
- Dossier spam/courrier indésirable
- SPF/DKIM/DMARC de votre domaine (pour éviter les filtres anti-spam)
- L'adresse `ACKIFY_MAIL_FROM` est vérifiée chez votre provider
### Template non trouvé
Vérifier :
- `ACKIFY_MAIL_TEMPLATE_DIR` pointe vers le bon répertoire
- La structure `{locale}/reminder.html` existe
- Les fichiers ont les bonnes permissions (readable)
### Timeout lors de l'envoi
Augmenter le timeout :
```bash
ACKIFY_MAIL_TIMEOUT=30s
```
## Bonnes Pratiques
### Production
- ✅ Utiliser un service SMTP dédié (SendGrid, Mailgun, SES)
- ✅ Vérifier votre domaine (SPF, DKIM, DMARC)
- ✅ Utiliser une adresse `noreply@` pour `ACKIFY_MAIL_FROM`
- ✅ Monitorer les `reminder_logs` pour détecter les échecs
- ✅ Tester régulièrement l'envoi d'emails
### Sécurité
- ✅ Ne jamais commiter `ACKIFY_MAIL_PASSWORD` dans git
- ✅ Utiliser des secrets Docker ou variables d'environnement
- ✅ Restreindre les permissions du compte SMTP
- ✅ Activer TLS/STARTTLS en production
### Performance
- Les emails sont envoyés **synchrones** lors de l'appel API
- Pour de gros volumes, envisager une queue asynchrone
- Limiter le nombre de destinataires par batch (recommandé : < 100)
## Désactiver les Emails
Pour désactiver complètement le service email :
```bash
# Supprimer ou commenter ACKIFY_MAIL_HOST
# ACKIFY_MAIL_HOST=
```
Le dashboard admin n'affichera plus les options d'envoi de rappels.

View File

@@ -0,0 +1,254 @@
# OAuth2 Providers
Configuration détaillée des différents providers OAuth2 supportés par Ackify.
## Providers Supportés
| Provider | Configuration | Auto-détection |
|----------|--------------|----------------|
| Google | `ACKIFY_OAUTH_PROVIDER=google` | ✅ |
| GitHub | `ACKIFY_OAUTH_PROVIDER=github` | ✅ |
| GitLab | `ACKIFY_OAUTH_PROVIDER=gitlab` | ✅ |
| Custom | Laisser vide + URLs manuelles | ❌ |
## Google OAuth2
### Étapes de configuration
1. Aller sur [Google Cloud Console](https://console.cloud.google.com/)
2. Créer un projet ou sélectionner un existant
3. Activer "Google+ API" (pour récupérer le profil utilisateur)
4. Créer des credentials OAuth 2.0 :
- **Application type** : Web application
- **Authorized JavaScript origins** : `https://sign.your-domain.com`
- **Authorized redirect URIs** : `https://sign.your-domain.com/api/v1/auth/callback`
### Configuration `.env`
```bash
ACKIFY_OAUTH_PROVIDER=google
ACKIFY_OAUTH_CLIENT_ID=123456789-abc.apps.googleusercontent.com
ACKIFY_OAUTH_CLIENT_SECRET=GOCSPX-xyz123abc
# Optionnel : restreindre aux emails @company.com
ACKIFY_OAUTH_ALLOWED_DOMAIN=@company.com
```
### Scopes automatiques
Par défaut, Ackify demande :
- `openid` - Identité OAuth2
- `email` - Adresse email
- `profile` - Nom complet
## GitHub OAuth2
### Étapes de configuration
1. Aller sur [GitHub Developer Settings](https://github.com/settings/developers)
2. Cliquer sur "New OAuth App"
3. Remplir le formulaire :
- **Application name** : Ackify
- **Homepage URL** : `https://sign.your-domain.com`
- **Authorization callback URL** : `https://sign.your-domain.com/api/v1/auth/callback`
4. Générer un client secret
### Configuration `.env`
```bash
ACKIFY_OAUTH_PROVIDER=github
ACKIFY_OAUTH_CLIENT_ID=Iv1.abc123xyz
ACKIFY_OAUTH_CLIENT_SECRET=ghp_1234567890abcdef
# Optionnel : restreindre aux emails vérifiés d'une organisation
ACKIFY_OAUTH_ALLOWED_DOMAIN=@company.com
```
### Scopes automatiques
Par défaut :
- `read:user` - Lecture du profil
- `user:email` - Accès aux emails
## GitLab OAuth2
### GitLab.com (public)
1. Aller sur [GitLab Applications](https://gitlab.com/-/profile/applications)
2. Créer une nouvelle application :
- **Name** : Ackify
- **Redirect URI** : `https://sign.your-domain.com/api/v1/auth/callback`
- **Scopes** : `openid`, `email`, `profile`
3. Copier l'Application ID et le Secret
```bash
ACKIFY_OAUTH_PROVIDER=gitlab
ACKIFY_OAUTH_CLIENT_ID=abc123xyz
ACKIFY_OAUTH_CLIENT_SECRET=glpat-xyz123
```
### GitLab Self-Hosted
Pour une instance GitLab privée :
```bash
ACKIFY_OAUTH_PROVIDER=gitlab
ACKIFY_OAUTH_GITLAB_URL=https://gitlab.company.com
ACKIFY_OAUTH_CLIENT_ID=abc123xyz
ACKIFY_OAUTH_CLIENT_SECRET=glpat-xyz123
```
**Important** : `ACKIFY_OAUTH_GITLAB_URL` doit pointer vers votre instance GitLab sans trailing slash.
## Custom OAuth2 Provider
Pour utiliser un provider OAuth2 non standard (Keycloak, Okta, Auth0, etc.).
### Configuration complète
```bash
# Ne pas définir ACKIFY_OAUTH_PROVIDER (ou laisser vide)
ACKIFY_OAUTH_PROVIDER=
# URLs manuelles
ACKIFY_OAUTH_AUTH_URL=https://auth.company.com/oauth/authorize
ACKIFY_OAUTH_TOKEN_URL=https://auth.company.com/oauth/token
ACKIFY_OAUTH_USERINFO_URL=https://auth.company.com/api/user
# Scopes personnalisés (optionnel)
ACKIFY_OAUTH_SCOPES=openid,email,profile
# URL de logout personnalisée (optionnel)
ACKIFY_OAUTH_LOGOUT_URL=https://auth.company.com/logout
# Credentials
ACKIFY_OAUTH_CLIENT_ID=your_client_id
ACKIFY_OAUTH_CLIENT_SECRET=your_client_secret
```
### Exemple avec Keycloak
```bash
ACKIFY_OAUTH_PROVIDER=
ACKIFY_OAUTH_AUTH_URL=https://keycloak.company.com/realms/myrealm/protocol/openid-connect/auth
ACKIFY_OAUTH_TOKEN_URL=https://keycloak.company.com/realms/myrealm/protocol/openid-connect/token
ACKIFY_OAUTH_USERINFO_URL=https://keycloak.company.com/realms/myrealm/protocol/openid-connect/userinfo
ACKIFY_OAUTH_LOGOUT_URL=https://keycloak.company.com/realms/myrealm/protocol/openid-connect/logout
ACKIFY_OAUTH_SCOPES=openid,email,profile
ACKIFY_OAUTH_CLIENT_ID=ackify-client
ACKIFY_OAUTH_CLIENT_SECRET=secret123
```
### Exemple avec Okta
```bash
ACKIFY_OAUTH_PROVIDER=
ACKIFY_OAUTH_AUTH_URL=https://dev-123456.okta.com/oauth2/default/v1/authorize
ACKIFY_OAUTH_TOKEN_URL=https://dev-123456.okta.com/oauth2/default/v1/token
ACKIFY_OAUTH_USERINFO_URL=https://dev-123456.okta.com/oauth2/default/v1/userinfo
ACKIFY_OAUTH_SCOPES=openid,email,profile
ACKIFY_OAUTH_CLIENT_ID=0oa123xyz
ACKIFY_OAUTH_CLIENT_SECRET=secret123
```
## Restriction de Domaine
Pour **tous les providers**, vous pouvez restreindre l'accès aux emails d'un domaine spécifique :
```bash
# Accepter uniquement les emails @company.com
ACKIFY_OAUTH_ALLOWED_DOMAIN=@company.com
```
**Comportement** :
- Les utilisateurs avec un email différent verront une erreur lors de la connexion
- La vérification est case-insensitive
- Fonctionne avec tous les providers (Google, GitHub, GitLab, custom)
## Auto-Login
Activer l'auto-login silencieux pour une meilleure UX :
```bash
ACKIFY_OAUTH_AUTO_LOGIN=true
```
**Fonctionnement** :
- Si l'utilisateur a déjà une session OAuth active, redirection automatique
- Pas de clic requis sur "Sign in"
- Utile pour les intégrations corporate (Google Workspace, Microsoft 365)
**Attention** : Peut créer des redirections infinies si mal configuré.
## Sécurité OAuth2
### PKCE (Proof Key for Code Exchange)
Ackify implémente **automatiquement** PKCE pour tous les providers :
- Protection contre l'interception du code d'autorisation
- Méthode : S256 (SHA-256)
- Activé par défaut, aucune configuration requise
### Refresh Tokens
Les refresh tokens sont :
- Stockés **chiffrés** dans PostgreSQL (AES-256-GCM)
- Utilisés pour maintenir les sessions 30 jours
- Automatiquement nettoyés après expiration (37 jours)
- Protégés par IP + User-Agent tracking
### Sessions Sécurisées
```bash
# Secret fort requis (minimum 32 bytes en base64)
ACKIFY_OAUTH_COOKIE_SECRET=$(openssl rand -base64 32)
```
Les cookies de session utilisent :
- HMAC-SHA256 pour l'intégrité
- Chiffrement AES-256-GCM
- Flags `Secure` (HTTPS uniquement) et `HttpOnly`
- `SameSite=Lax` pour protection CSRF
## Troubleshooting
### Erreur "invalid redirect_uri"
Vérifier que :
- `ACKIFY_BASE_URL` correspond exactement à votre domaine
- La callback URL dans le provider inclut `/api/v1/auth/callback`
- Pas de trailing slash dans `ACKIFY_BASE_URL`
### Erreur "unauthorized_client"
Vérifier :
- Le `client_id` et `client_secret` sont corrects
- L'application OAuth est bien activée côté provider
- Les scopes demandés sont autorisés
### Erreur "access_denied"
L'utilisateur a refusé l'autorisation, ou :
- Son email ne correspond pas à `ACKIFY_OAUTH_ALLOWED_DOMAIN`
- L'application n'a pas les permissions requises
### Custom provider ne fonctionne pas
Vérifier :
- `ACKIFY_OAUTH_PROVIDER` est **vide** ou non défini
- Les 3 URLs (auth, token, userinfo) sont complètes et correctes
- La réponse de `/userinfo` contient bien `sub`, `email`, `name`
## Tester la Configuration
```bash
# Redémarrer après modification
docker compose restart ackify-ce
# Tester la connexion OAuth
curl -X POST http://localhost:8080/api/v1/auth/start \
-H "Content-Type: application/json" \
-d '{"redirect_to": "/"}'
# Devrait retourner une redirect_url vers le provider OAuth
```

604
docs/fr/database.md Normal file
View File

@@ -0,0 +1,604 @@
# Database
Schéma PostgreSQL, migrations, et garanties d'intégrité.
## Vue d'Ensemble
Ackify utilise **PostgreSQL 16+** avec :
- Migrations versionnées SQL
- Contraintes d'intégrité strictes
- Triggers pour immutabilité
- Index pour performances
## Schéma Principal
### Table `signatures`
Stocke les signatures cryptographiques Ed25519.
```sql
CREATE TABLE signatures (
id BIGSERIAL PRIMARY KEY,
doc_id TEXT NOT NULL,
user_sub TEXT NOT NULL, -- OAuth user ID (sub claim)
user_email TEXT NOT NULL,
user_name TEXT, -- Nom utilisateur (optionnel)
signed_at TIMESTAMPTZ NOT NULL,
payload_hash TEXT NOT NULL, -- SHA-256 du payload
signature TEXT NOT NULL, -- Signature Ed25519 (base64)
nonce TEXT NOT NULL, -- Anti-replay attack
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
referer TEXT, -- Source (optionnel)
prev_hash TEXT, -- Hash de la signature précédente (chaînage)
UNIQUE (doc_id, user_sub) -- UNE signature par user/document
);
CREATE INDEX idx_signatures_doc_id ON signatures(doc_id);
CREATE INDEX idx_signatures_user_sub ON signatures(user_sub);
```
**Garanties** :
- ✅ Une signature par utilisateur/document (contrainte UNIQUE)
- ✅ Horodatage immutable via trigger PostgreSQL
- ✅ Chaînage hash (blockchain-like) via `prev_hash`
- ✅ Non-répudiation cryptographique (Ed25519)
### Table `documents`
Métadonnées des documents.
```sql
CREATE TABLE documents (
doc_id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '', -- URL du document source
checksum TEXT NOT NULL DEFAULT '', -- SHA-256, SHA-512, ou MD5
checksum_algorithm TEXT NOT NULL DEFAULT 'SHA-256',
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by TEXT NOT NULL DEFAULT '' -- user_sub de l'admin créateur
);
```
**Utilisation** :
- Titre, description affichés dans l'interface
- URL incluse dans les emails de rappel
- Checksum pour vérification d'intégrité (optionnel)
### Table `expected_signers`
Signataires attendus pour tracking.
```sql
CREATE TABLE expected_signers (
id BIGSERIAL PRIMARY KEY,
doc_id TEXT NOT NULL,
email TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '', -- Nom pour personnalisation
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
added_by TEXT NOT NULL, -- Admin qui a ajouté
notes TEXT,
UNIQUE (doc_id, email)
);
CREATE INDEX idx_expected_signers_doc_id ON expected_signers(doc_id);
```
**Fonctionnalités** :
- Tracking de complétion (% signé)
- Envoi de rappels email
- Détection de signatures inattendues
### Table `reminder_logs`
Historique des rappels email.
```sql
CREATE TABLE reminder_logs (
id BIGSERIAL PRIMARY KEY,
doc_id TEXT NOT NULL,
recipient_email TEXT NOT NULL,
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_by TEXT NOT NULL, -- Admin qui a envoyé
template_used TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('sent', 'failed', 'bounced')),
error_message TEXT,
FOREIGN KEY (doc_id, recipient_email)
REFERENCES expected_signers(doc_id, email)
);
CREATE INDEX idx_reminder_logs_doc_id ON reminder_logs(doc_id);
```
### Table `checksum_verifications`
Historique des vérifications d'intégrité.
```sql
CREATE TABLE checksum_verifications (
id BIGSERIAL PRIMARY KEY,
doc_id TEXT NOT NULL,
verified_by TEXT NOT NULL,
verified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
stored_checksum TEXT NOT NULL,
calculated_checksum TEXT NOT NULL,
algorithm TEXT NOT NULL,
is_valid BOOLEAN NOT NULL,
error_message TEXT,
FOREIGN KEY (doc_id) REFERENCES documents(doc_id)
);
CREATE INDEX idx_checksum_verifications_doc_id ON checksum_verifications(doc_id);
```
### Table `oauth_sessions`
Sessions OAuth2 avec refresh tokens chiffrés.
```sql
CREATE TABLE oauth_sessions (
id BIGSERIAL PRIMARY KEY,
session_id TEXT NOT NULL UNIQUE, -- ID session Gorilla
user_sub TEXT NOT NULL, -- OAuth user ID
refresh_token_encrypted BYTEA NOT NULL, -- Chiffré AES-256-GCM
access_token_expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_refreshed_at TIMESTAMPTZ,
user_agent TEXT,
ip_address INET
);
CREATE INDEX idx_oauth_sessions_session_id ON oauth_sessions(session_id);
CREATE INDEX idx_oauth_sessions_user_sub ON oauth_sessions(user_sub);
CREATE INDEX idx_oauth_sessions_updated_at ON oauth_sessions(updated_at);
```
**Sécurité** :
- Refresh tokens chiffrés (AES-256-GCM)
- Cleanup automatique après 37 jours
- Tracking IP + User-Agent pour détecter vols
### Table `email_queue`
File d'attente d'emails asynchrone avec mécanisme de retry.
```sql
CREATE TABLE email_queue (
id BIGSERIAL PRIMARY KEY,
-- Métadonnées email
to_addresses TEXT[] NOT NULL, -- Adresses email destinataires
cc_addresses TEXT[], -- Adresses CC (optionnel)
bcc_addresses TEXT[], -- Adresses BCC (optionnel)
subject TEXT NOT NULL, -- Sujet de l'email
template TEXT NOT NULL, -- Nom du template (ex: 'reminder')
locale TEXT NOT NULL DEFAULT 'fr', -- Langue email (en, fr, es, de, it)
data JSONB NOT NULL DEFAULT '{}', -- Variables du template
headers JSONB, -- Headers email personnalisés (optionnel)
-- Gestion de la file
status TEXT NOT NULL DEFAULT 'pending' -- pending, processing, sent, failed, cancelled
CHECK (status IN ('pending', 'processing', 'sent', 'failed', 'cancelled')),
priority INT NOT NULL DEFAULT 0, -- Plus élevé = traité en premier (0=normal, 10=high, 100=urgent)
retry_count INT NOT NULL DEFAULT 0, -- Nombre de tentatives de retry
max_retries INT NOT NULL DEFAULT 3, -- Limite maximale de retry
-- Suivi
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
scheduled_for TIMESTAMPTZ NOT NULL DEFAULT now(), -- Heure de traitement la plus tôt
processed_at TIMESTAMPTZ, -- Quand l'email a été envoyé
next_retry_at TIMESTAMPTZ, -- Heure de retry calculée (exponential backoff)
-- Suivi des erreurs
last_error TEXT, -- Dernier message d'erreur
error_details JSONB, -- Informations d'erreur détaillées
-- Suivi des références (optionnel)
reference_type TEXT, -- ex: 'reminder', 'notification'
reference_id TEXT, -- ex: doc_id
created_by TEXT -- Utilisateur qui a mis en file l'email
);
-- Index pour traitement efficace de la file
CREATE INDEX idx_email_queue_status_scheduled
ON email_queue(status, scheduled_for)
WHERE status IN ('pending', 'processing');
CREATE INDEX idx_email_queue_priority_scheduled
ON email_queue(priority DESC, scheduled_for ASC)
WHERE status = 'pending';
CREATE INDEX idx_email_queue_retry
ON email_queue(next_retry_at)
WHERE status = 'processing' AND retry_count < max_retries;
CREATE INDEX idx_email_queue_reference
ON email_queue(reference_type, reference_id);
CREATE INDEX idx_email_queue_created_at
ON email_queue(created_at DESC);
```
**Fonctionnalités** :
- **Traitement asynchrone** : Emails traités par worker en arrière-plan
- **Mécanisme de retry** : Exponential backoff (1min, 2min, 4min, 8min, 16min, 32min...)
- **Support de priorité** : Emails haute priorité traités en premier
- **Envoi programmé** : Retarder la livraison d'email avec `scheduled_for`
- **Suivi des erreurs** : Logging détaillé des erreurs et historique des retries
- **Suivi des références** : Lier les emails aux documents ou autres entités
**Calcul automatique du retry** :
```sql
-- Fonction pour calculer le temps de retry suivant avec exponential backoff
CREATE OR REPLACE FUNCTION calculate_next_retry_time(retry_count INT)
RETURNS TIMESTAMPTZ AS $$
BEGIN
-- Exponential backoff: 1min, 2min, 4min, 8min, 16min, 32min...
RETURN now() + (interval '1 minute' * power(2, retry_count));
END;
$$ LANGUAGE plpgsql;
```
**Configuration du worker** :
- Taille de lot : 10 emails par lot
- Intervalle de polling : 5 secondes
- Envois concurrents : 5 emails simultanés
- Cleanup des anciens emails : Rétention de 7 jours pour emails envoyés/échoués
## Migrations
### Gestion des Migrations
Les migrations sont dans `/backend/migrations/` avec le format :
```
XXXX_description.up.sql # Migration "up"
XXXX_description.down.sql # Rollback "down"
```
**Fichiers actuels** :
- `0001_init.up.sql` - Table signatures
- `0002_expected_signers.up.sql` - Expected signers
- `0003_reminder_logs.up.sql` - Reminder logs
- `0004_add_name_to_expected_signers.up.sql` - Noms signataires
- `0005_create_documents_table.up.sql` - Documents metadata
- `0006_create_new_tables.up.sql` - Checksum verifications et email queue
- `0007_oauth_sessions.up.sql` - OAuth sessions avec refresh tokens
### Appliquer les Migrations
**Via Docker Compose** (automatique) :
```bash
docker compose up -d
# Le service ackify-migrate applique les migrations au démarrage
```
**Manuellement** :
```bash
cd backend
go run ./cmd/migrate up
```
**Rollback dernière migration** :
```bash
go run ./cmd/migrate down
```
### Migrations Personnalisées
Pour créer une nouvelle migration :
1. Créer `XXXX_my_feature.up.sql` :
```sql
-- Migration up
ALTER TABLE signatures ADD COLUMN new_field TEXT;
```
2. Créer `XXXX_my_feature.down.sql` :
```sql
-- Rollback
ALTER TABLE signatures DROP COLUMN new_field;
```
3. Appliquer :
```bash
go run ./cmd/migrate up
```
## Triggers PostgreSQL
### Immutabilité de `created_at`
Trigger qui empêche la modification de `created_at` :
```sql
CREATE OR REPLACE FUNCTION prevent_created_at_update()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.created_at <> OLD.created_at THEN
RAISE EXCEPTION 'created_at cannot be modified';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER prevent_signatures_created_at_update
BEFORE UPDATE ON signatures
FOR EACH ROW
EXECUTE FUNCTION prevent_created_at_update();
```
**Garantie** : Aucune signature ne peut être backdatée.
### Auto-update de `updated_at`
Pour les tables avec `updated_at` :
```sql
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_documents_updated_at
BEFORE UPDATE ON documents
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
```
## Requêtes Utiles
### Voir les signatures d'un document
```sql
SELECT
user_email,
user_name,
signed_at,
payload_hash,
signature
FROM signatures
WHERE doc_id = 'my_document'
ORDER BY signed_at DESC;
```
### Statut de complétion
```sql
WITH expected AS (
SELECT COUNT(*) as total
FROM expected_signers
WHERE doc_id = 'my_document'
),
signed AS (
SELECT COUNT(*) as count
FROM signatures s
INNER JOIN expected_signers e ON s.user_email = e.email AND s.doc_id = e.doc_id
WHERE s.doc_id = 'my_document'
)
SELECT
e.total as expected,
s.count as signed,
ROUND(100.0 * s.count / NULLIF(e.total, 0), 2) as completion_pct
FROM expected e, signed s;
```
### Signataires manquants
```sql
SELECT
e.email,
e.name,
e.added_at
FROM expected_signers e
LEFT JOIN signatures s ON e.email = s.user_email AND e.doc_id = s.doc_id
WHERE e.doc_id = 'my_document' AND s.id IS NULL
ORDER BY e.added_at;
```
### Signatures inattendues
```sql
SELECT
s.user_email,
s.signed_at
FROM signatures s
LEFT JOIN expected_signers e ON s.user_email = e.email AND s.doc_id = e.doc_id
WHERE s.doc_id = 'my_document' AND e.id IS NULL
ORDER BY s.signed_at DESC;
```
### Statut de la file d'emails
```sql
-- Voir les emails en attente
SELECT
id,
to_addresses,
subject,
status,
priority,
retry_count,
scheduled_for,
created_at
FROM email_queue
WHERE status IN ('pending', 'processing')
ORDER BY priority DESC, scheduled_for ASC
LIMIT 20;
-- Emails échoués nécessitant attention
SELECT
id,
to_addresses,
subject,
retry_count,
max_retries,
last_error,
next_retry_at
FROM email_queue
WHERE status = 'failed'
ORDER BY created_at DESC;
-- Statistiques des emails par statut
SELECT
status,
COUNT(*) as count,
MIN(created_at) as oldest,
MAX(created_at) as newest
FROM email_queue
GROUP BY status
ORDER BY status;
```
## Sauvegarde & Restauration
### Backup PostgreSQL
```bash
# Backup complet
docker compose exec ackify-db pg_dump -U ackifyr ackify > backup.sql
# Backup avec compression
docker compose exec ackify-db pg_dump -U ackifyr ackify | gzip > backup.sql.gz
```
### Restore
```bash
# Restore depuis backup
cat backup.sql | docker compose exec -T ackify-db psql -U ackifyr ackify
# Restore depuis backup compressé
gunzip -c backup.sql.gz | docker compose exec -T ackify-db psql -U ackifyr ackify
```
### Backup Automatisé
Exemple de cron pour backup quotidien :
```bash
0 2 * * * docker compose -f /path/to/compose.yml exec -T ackify-db pg_dump -U ackifyr ackify | gzip > /backups/ackify-$(date +\%Y\%m\%d).sql.gz
```
## Performance
### Index
Les index sont automatiquement créés pour :
- `signatures(doc_id)` - Requêtes par document
- `signatures(user_sub)` - Requêtes par utilisateur
- `expected_signers(doc_id)` - Tracking complétion
- `oauth_sessions(session_id)` - Lookup sessions
### Connection Pooling
Le backend Go gère automatiquement le pooling de connexions :
- Max open connections : 25
- Max idle connections : 5
- Connection max lifetime : 5 minutes
### Vacuum & Analyze
PostgreSQL gère automatiquement via `autovacuum`. Pour forcer :
```sql
VACUUM ANALYZE signatures;
VACUUM ANALYZE documents;
```
## Monitoring
### Taille des tables
```sql
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
```
### Statistiques
```sql
SELECT * FROM pg_stat_user_tables WHERE schemaname = 'public';
```
### Connexions actives
```sql
SELECT
datname,
usename,
application_name,
client_addr,
state,
query
FROM pg_stat_activity
WHERE datname = 'ackify';
```
## Sécurité
### En Production
- ✅ Utiliser SSL : `?sslmode=require` dans le DSN
- ✅ Mot de passe fort pour PostgreSQL
- ✅ Restreindre les connexions réseau
- ✅ Sauvegardes chiffrées
- ✅ Rotation régulière des secrets
### Configuration SSL
```bash
# Dans .env
ACKIFY_DB_DSN=postgres://user:pass@host:5432/ackify?sslmode=require
```
### Audit Trail
Toutes les opérations importantes sont tracées :
- `signatures.created_at` - Horodatage signature
- `expected_signers.added_by` - Qui a ajouté
- `reminder_logs.sent_by` - Qui a envoyé le rappel
- `checksum_verifications.verified_by` - Qui a vérifié
## Troubleshooting
### Migrations bloquées
```bash
# Vérifier le statut
docker compose logs ackify-migrate
# Forcer le rollback
docker compose exec ackify-ce /app/migrate down
docker compose exec ackify-ce /app/migrate up
```
### Contrainte UNIQUE violée
Erreur : `duplicate key value violates unique constraint`
**Cause** : L'utilisateur a déjà signé ce document.
**Solution** : C'est un comportement normal (une signature par user/doc).
### Connection refused
Vérifier que PostgreSQL est démarré :
```bash
docker compose ps ackify-db
docker compose logs ackify-db
```

122
docs/fr/deployment.md Normal file
View File

@@ -0,0 +1,122 @@
# Deployment
Guide de déploiement en production avec Docker Compose.
## Production avec Docker Compose
### Architecture Recommandée
```
[Internet] → [Reverse Proxy (Traefik/Nginx)] → [Ackify Container]
[PostgreSQL Container]
```
### compose.yml Production
Voir le fichier `/compose.yml` à la racine du projet pour la configuration complète.
**Services inclus** :
- `ackify-migrate` - Migrations PostgreSQL (run once)
- `ackify-ce` - Application principale
- `ackify-db` - PostgreSQL 16
### Configuration .env Production
```bash
# Application
APP_DNS=sign.company.com
ACKIFY_BASE_URL=https://sign.company.com
ACKIFY_ORGANISATION="ACME Corporation"
ACKIFY_LOG_LEVEL=info
# Base de données (mot de passe fort)
POSTGRES_USER=ackifyr
POSTGRES_PASSWORD=$(openssl rand -base64 32)
POSTGRES_DB=ackify
# OAuth2
ACKIFY_OAUTH_PROVIDER=google
ACKIFY_OAUTH_CLIENT_ID=your_client_id
ACKIFY_OAUTH_CLIENT_SECRET=your_client_secret
ACKIFY_OAUTH_ALLOWED_DOMAIN=@company.com
# Sécurité (générer avec openssl)
ACKIFY_OAUTH_COOKIE_SECRET=$(openssl rand -base64 64)
ACKIFY_ED25519_PRIVATE_KEY=$(openssl rand -base64 64)
# Administration
ACKIFY_ADMIN_EMAILS=admin@company.com,cto@company.com
```
## Reverse Proxy
### Traefik
Ajouter les labels dans `compose.yml` :
```yaml
services:
ackify-ce:
labels:
- "traefik.enable=true"
- "traefik.http.routers.ackify.rule=Host(`sign.company.com`)"
- "traefik.http.routers.ackify.entrypoints=websecure"
- "traefik.http.routers.ackify.tls.certresolver=letsencrypt"
```
### Nginx
```nginx
server {
listen 443 ssl http2;
server_name sign.company.com;
ssl_certificate /etc/letsencrypt/live/sign.company.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sign.company.com/privkey.pem;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
## Checklist Sécurité
- ✅ HTTPS avec certificat valide
- ✅ Secrets forts (64+ bytes)
- ✅ PostgreSQL SSL en production
- ✅ Domaine OAuth restreint
- ✅ Logs en mode info
- ✅ Backup automatique
- ✅ Monitoring actif
## Backup
```bash
# Backup quotidien PostgreSQL
docker compose exec -T ackify-db pg_dump -U ackifyr ackify | gzip > backup-$(date +%Y%m%d).sql.gz
# Restauration
gunzip -c backup.sql.gz | docker compose exec -T ackify-db psql -U ackifyr ackify
```
## Mise à Jour
```bash
# Pull nouvelle image
docker compose pull ackify-ce
# Redémarrer
docker compose up -d
# Vérifier
docker compose logs -f ackify-ce
curl https://sign.company.com/api/v1/health
```
Voir [Getting Started](getting-started.md) pour plus de détails.

513
docs/fr/development.md Normal file
View File

@@ -0,0 +1,513 @@
# Development
Guide pour contribuer et développer sur Ackify.
## Setup Développement
### Prérequis
- **Go 1.24.5+**
- **Node.js 22+** et npm
- **PostgreSQL 16+**
- **Docker & Docker Compose**
- Git
### Clone & Setup
```bash
# Clone
git clone https://github.com/btouchard/ackify-ce.git
cd ackify-ce
# Copier .env
cp .env.example .env
# Éditer .env avec vos credentials OAuth2
nano .env
```
## Développement Backend
### Build
```bash
cd backend
go mod download
go build ./cmd/community
```
### Run
```bash
# Démarrer PostgreSQL avec Docker
docker compose up -d ackify-db
# Appliquer les migrations
go run ./cmd/migrate up
# Lancer l'app
./community
```
L'API est accessible sur `http://localhost:8080`.
### Tests
```bash
# Tests unitaires
go test -v -short ./...
# Tests avec coverage
go test -coverprofile=coverage.out ./internal/... ./pkg/...
# Voir le coverage
go tool cover -html=coverage.out
# Tests d'intégration (PostgreSQL requis)
docker compose -f ../compose.test.yml up -d
INTEGRATION_TESTS=1 go test -tags=integration -v ./internal/infrastructure/database/
docker compose -f ../compose.test.yml down
```
### Linting
```bash
# Format
go fmt ./...
# Vet
go vet ./...
# Staticcheck (optionnel)
go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck ./...
```
## Développement Frontend
### Setup
```bash
cd webapp
npm install
```
### Dev Server
```bash
npm run dev
```
Frontend accessible sur `http://localhost:5173` avec Hot Module Replacement.
### Build Production
```bash
npm run build
# Output: webapp/dist/
```
### Type Checking
```bash
npm run type-check
```
### i18n Validation
```bash
npm run lint:i18n
```
Vérifie que toutes les traductions sont complètes.
## Docker Development
### Build Local
```bash
# Build l'image complète (frontend + backend)
docker compose -f compose.local.yml up -d --build
# Logs
docker compose -f compose.local.yml logs -f ackify-ce
# Rebuild après modifications
docker compose -f compose.local.yml up -d --force-recreate ackify-ce --build
```
### Debug
```bash
# Shell dans le container
docker compose exec ackify-ce sh
# PostgreSQL shell
docker compose exec ackify-db psql -U ackifyr -d ackify
```
## Structure du Code
### Backend
```
backend/
├── cmd/
│ ├── community/ # main.go + injection dépendances
│ └── migrate/ # Outil migrations
├── internal/
│ ├── domain/models/ # Entités (User, Signature, Document)
│ ├── application/services/ # Logique métier
│ ├── infrastructure/
│ │ ├── auth/ # OAuth2
│ │ ├── database/ # Repositories
│ │ ├── email/ # SMTP
│ │ └── config/ # Config
│ └── presentation/api/ # HTTP handlers
└── pkg/ # Utilities
```
### Frontend
```
webapp/src/
├── components/ # Composants Vue
├── pages/ # Pages (router)
├── services/ # API client
├── stores/ # Pinia stores
├── router/ # Vue Router
└── locales/ # Traductions
```
## Conventions de Code
### Go
**Naming** :
- Packages : lowercase, singular (`user`, `signature`)
- Interfaces : suffixe `er` ou descriptif (`SignatureRepository`, `EmailSender`)
- Constructeurs : `New...()` ou `...From...()`
**Exemple** :
```go
// Service
type SignatureService struct {
repo SignatureRepository
crypto CryptoService
}
func NewSignatureService(repo SignatureRepository, crypto CryptoService) *SignatureService {
return &SignatureService{repo: repo, crypto: crypto}
}
// Méthode
func (s *SignatureService) CreateSignature(ctx context.Context, docID, userSub string) (*models.Signature, error) {
// ...
}
```
**Erreurs** :
```go
// Wrapping
return nil, fmt.Errorf("failed to create signature: %w", err)
// Custom errors
var ErrAlreadySigned = errors.New("user has already signed this document")
```
### TypeScript
**Naming** :
- Components : PascalCase (`DocumentCard.vue`)
- Composables : camelCase avec `use` prefix (`useAuth.ts`)
- Stores : camelCase avec `Store` suffix (`userStore.ts`)
**Exemple** :
```typescript
// Composable
export function useAuth() {
const user = ref<User | null>(null)
async function login() {
// ...
}
return { user, login }
}
// Store
export const useUserStore = defineStore('user', () => {
const currentUser = ref<User | null>(null)
async function fetchMe() {
const { data } = await api.get('/users/me')
currentUser.value = data
}
return { currentUser, fetchMe }
})
```
## Ajouter une Feature
### 1. Planifier
- Définir les endpoints API requis
- Schéma SQL si nécessaire
- Interface utilisateur
### 2. Backend
```bash
# 1. Créer migration si besoin
touch backend/migrations/XXXX_my_feature.up.sql
touch backend/migrations/XXXX_my_feature.down.sql
# 2. Créer le modèle
# backend/internal/domain/models/my_model.go
# 3. Créer le repository interface
# backend/internal/application/services/my_service.go
# 4. Implémenter le repository
# backend/internal/infrastructure/database/my_repository.go
# 5. Créer le handler API
# backend/internal/presentation/api/myfeature/handler.go
# 6. Enregistrer les routes
# backend/internal/presentation/api/router.go
```
### 3. Frontend
```bash
# 1. Créer le service API
# webapp/src/services/myFeatureService.ts
# 2. Créer le store Pinia
# webapp/src/stores/myFeatureStore.ts
# 3. Créer les composants
# webapp/src/components/MyFeature.vue
# 4. Ajouter les traductions
# webapp/src/locales/{fr,en,es,de,it}.json
# 5. Ajouter les routes si nécessaire
# webapp/src/router/index.ts
```
### 4. Tests
```bash
# Backend
# backend/internal/presentation/api/myfeature/handler_test.go
# Test
go test -v ./internal/presentation/api/myfeature/
```
### 5. Documentation
Mettre à jour :
- `/api/openapi.yaml` - Spécification OpenAPI
- `/docs/api.md` - Documentation API
- `/docs/features/my-feature.md` - Guide utilisateur
## Debugging
### Backend
```go
// Logs structurés
logger.Info("signature created",
"doc_id", docID,
"user_sub", userSub,
"signature_id", sig.ID,
)
// Debug via Delve (optionnel)
dlv debug ./cmd/community
```
### Frontend
```typescript
// Vue DevTools (extension Chrome/Firefox)
// Inspecter: Components, Pinia stores, Router
// Console debug
console.log('[DEBUG] User:', user.value)
// Breakpoints via navigateur
debugger
```
## Migrations SQL
### Créer une Migration
```sql
-- XXXX_add_field.up.sql
ALTER TABLE signatures ADD COLUMN new_field TEXT;
-- XXXX_add_field.down.sql
ALTER TABLE signatures DROP COLUMN new_field;
```
### Appliquer
```bash
go run ./cmd/migrate up
```
### Rollback
```bash
go run ./cmd/migrate down
```
## Tests d'Intégration
### Setup PostgreSQL Test
```bash
docker compose -f compose.test.yml up -d
```
### Run Tests
```bash
INTEGRATION_TESTS=1 go test -tags=integration -v ./internal/infrastructure/database/
```
### Cleanup
```bash
docker compose -f compose.test.yml down -v
```
## CI/CD
### GitHub Actions
Le projet utilise `.github/workflows/ci.yml` :
**Jobs** :
1. **Lint** - go fmt, go vet, eslint
2. **Test Backend** - Unit + integration tests
3. **Test Frontend** - Type checking + i18n validation
4. **Coverage** - Upload vers Codecov
5. **Build** - Vérifier que l'image Docker build
### Pre-commit Hooks (optionnel)
```bash
# Installer pre-commit
pip install pre-commit
# Setup hooks
pre-commit install
# Run manuellement
pre-commit run --all-files
```
## Contribution
### Workflow Git
```bash
# 1. Créer une branche
git checkout -b feature/my-feature
# 2. Développer + commit
git add .
git commit -m "feat: add my feature"
# 3. Push
git push origin feature/my-feature
# 4. Créer une Pull Request sur GitHub
```
### Commit Messages
Format : `type: description`
**Types** :
- `feat` - Nouvelle feature
- `fix` - Bug fix
- `docs` - Documentation
- `refactor` - Refactoring
- `test` - Tests
- `chore` - Maintenance
**Exemples** :
```
feat: add checksum verification feature
fix: resolve OAuth callback redirect loop
docs: update API documentation for signatures
refactor: simplify signature service logic
test: add integration tests for expected signers
```
### Code Review
**Checklist** :
- ✅ Tests passent (CI green)
- ✅ Code formaté (`go fmt`, `eslint`)
- ✅ Pas de secrets commitées
- ✅ Documentation à jour
- ✅ Traductions complètes (i18n)
## Troubleshooting
### Backend ne démarre pas
```bash
# Vérifier PostgreSQL
docker compose ps ackify-db
docker compose logs ackify-db
# Vérifier les variables d'env
cat .env
# Logs détaillés
ACKIFY_LOG_LEVEL=debug ./community
```
### Frontend build échoue
```bash
# Nettoyer et réinstaller
rm -rf node_modules package-lock.json
npm install
# Vérifier Node version
node --version # Doit être 22+
```
### Tests échouent
```bash
# Backend - vérifier PostgreSQL test
docker compose -f compose.test.yml ps
# Frontend - vérifier types
npm run type-check
```
## Resources
- [Go Documentation](https://go.dev/doc/)
- [Vue 3 Guide](https://vuejs.org/guide/)
- [PostgreSQL Docs](https://www.postgresql.org/docs/)
- [Chi Router](https://github.com/go-chi/chi)
- [Pinia](https://pinia.vuejs.org/)
## Support
- [GitHub Issues](https://github.com/btouchard/ackify-ce/issues)
- [GitHub Discussions](https://github.com/btouchard/ackify-ce/discussions)

View File

@@ -0,0 +1,235 @@
# Checksums
Vérification d'intégrité des documents avec tracking.
## Vue d'Ensemble
Ackify permet de stocker et vérifier les checksums (empreintes) des documents pour garantir leur intégrité.
**Algorithmes supportés** :
- SHA-256 (recommandé)
- SHA-512
- MD5 (legacy)
## Calculer un Checksum
### Ligne de Commande
```bash
# Linux/Mac - SHA-256
sha256sum document.pdf
# Output: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 document.pdf
# SHA-512
sha512sum document.pdf
# MD5
md5sum document.pdf
# Windows PowerShell
Get-FileHash document.pdf -Algorithm SHA256
Get-FileHash document.pdf -Algorithm SHA512
Get-FileHash document.pdf -Algorithm MD5
```
### Client-Side (JavaScript)
Le frontend Vue.js utilise la **Web Crypto API** :
```javascript
async function calculateChecksum(file) {
const arrayBuffer = await file.arrayBuffer()
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}
// Utilisation
const file = document.querySelector('input[type="file"]').files[0]
const checksum = await calculateChecksum(file)
console.log('SHA-256:', checksum)
```
## Stocker le Checksum
### Via le Dashboard Admin
1. Aller sur `/admin`
2. Sélectionner un document
3. Cliquer "Edit Metadata"
4. Remplir :
- **Checksum** : e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
- **Algorithm** : SHA-256
- **Document URL** : https://docs.company.com/policy.pdf
### Via l'API
```http
PUT /api/v1/admin/documents/policy_2025/metadata
Content-Type: application/json
X-CSRF-Token: abc123
{
"title": "Security Policy 2025",
"url": "https://docs.company.com/policy.pdf",
"checksum": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"checksumAlgorithm": "SHA-256",
"description": "Annual security policy"
}
```
## Vérification
### Interface Utilisateur
Le frontend affiche :
```
Document: Security Policy 2025
Checksum (SHA-256): e3b0c44...52b855 [Copy]
URL: https://docs.company.com/policy.pdf [Open]
[Upload file to verify]
```
**Workflow utilisateur** :
1. Télécharge le document depuis l'URL
2. Upload dans l'interface de vérification
3. Le checksum est calculé client-side
4. Comparaison automatique avec le stocké
5. ✅ Match ou ❌ Mismatch
### Vérification Manuelle
```bash
# 1. Télécharger le document
wget https://docs.company.com/policy.pdf
# 2. Calculer le checksum
sha256sum policy.pdf
# e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
# 3. Comparer avec la valeur stockée (via API)
curl http://localhost:8080/api/v1/documents/policy_2025
# "checksum": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
# 4. Si identique → Document intègre
```
## Cas d'Usage
### Compliance Documentaire
```
Document: "ISO 27001 Certification"
Checksum: SHA-256 du PDF officiel
```
**Workflow** :
- Stocker le checksum du document certifié
- Chaque reviewer vérifie l'intégrité avant signature
- Audit trail de toutes les vérifications
### Contrat Légal
```
Document: "Service Agreement v2.3"
Checksum: SHA-512 pour sécurité maximale
URL: https://legal.company.com/contracts/sa-v2.3.pdf
```
**Garanties** :
- Le document signé correspond exactement à la version checksum
- Détection de toute modification
- Traçabilité des vérifications
### Formation avec Support
```
Document: "GDPR Training Materials"
Checksum: SHA-256 du fichier ZIP
```
**Utilisation** :
- Participants téléchargent le ZIP
- Vérifient le checksum avant de commencer
- Signent après complétion
## Sécurité
### Choix de l'Algorithme
| Algorithme | Sécurité | Performance | Recommandation |
|------------|----------|-------------|----------------|
| SHA-256 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ✅ Recommandé |
| SHA-512 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | Maximum security |
| MD5 | ⭐⭐ | ⭐⭐⭐⭐⭐ | ❌ Legacy only |
**Recommandation** : Utiliser **SHA-256** par défaut.
### Limitations de MD5
MD5 est **déprécié** pour la sécurité :
- Collisions possibles (deux fichiers différents = même hash)
- Utilisable uniquement pour compatibilité legacy
### Web Crypto API
La vérification client-side utilise l'API native du navigateur :
- Pas de dépendance externe
- Performance native
- Supporté par tous les navigateurs modernes
## Intégration avec Signatures
Workflow complet :
```
1. Admin upload document → calcule checksum → stocke metadata
2. User télécharge document → vérifie checksum client-side
3. Si checksum OK → User signe le document
4. Signature liée au doc_id avec checksum stocké
```
**Garantie** : La signature prouve que l'utilisateur a lu **exactement** la version checksum.
## Bonnes Pratiques
### Stockage
- ✅ Toujours stocker le checksum **avant** d'envoyer le lien de signature
- ✅ Inclure l'URL du document dans la metadata
- ✅ Utiliser SHA-256 minimum
- ✅ Documenter l'algorithme utilisé
### Vérification
- ✅ Encourager les utilisateurs à vérifier avant de signer
- ✅ Afficher le checksum de manière visible (avec bouton Copy)
- ✅ Alerter en cas de mismatch
### Audit
- ✅ Surveiller l'intégrité des documents
- ✅ Vérifier régulièrement les checksums
## Limitations
- **Vérification manuelle uniquement** - Les utilisateurs doivent calculer et comparer les checksums manuellement
- **Pas d'API de vérification côté serveur** - La vérification des checksums se fait côté client ou manuellement
- **Pas d'historique automatisé** - La table `checksum_verifications` existe dans le schéma de base de données mais n'est pas actuellement utilisée par l'API
- Pas de signature du checksum (fonctionnalité future : signer le checksum avec Ed25519)
- Pas d'intégration avec stockage cloud (S3, GCS) pour récupération automatique
## Implémentation Actuelle
Actuellement, Ackify supporte :
- ✅ Stockage des checksums dans les métadonnées de document (via dashboard admin ou API)
- ✅ Affichage des checksums aux utilisateurs pour vérification manuelle
- ✅ Calcul de checksum côté client avec Web Crypto API
- ✅ Calcul automatique de checksum pour les URLs distantes (admin uniquement)
Les fonctionnalités futures pourraient inclure :
- Endpoints API pour le suivi des vérifications de checksum
- Workflows de vérification automatisés
- Intégration avec des services de vérification externes

View File

@@ -0,0 +1,339 @@
# Embedding & Intégrations
Intégrer Ackify dans vos outils (Notion, Outline, Google Docs, etc.).
## Méthodes d'Intégration
### 1. Lien Direct
Le plus simple :
```
https://sign.company.com/?doc=policy_2025
```
**Usage** :
- Email
- Chat (Slack, Teams)
- Wiki
- Documentation
**Comportement** :
- L'utilisateur clique → Arrive sur la page de signature
- Se connecte via OAuth2
- Signe le document
### 2. iFrame Embed
Pour intégrer dans une page web :
```html
<iframe src="https://sign.company.com/?doc=policy_2025"
width="600"
height="200"
frameborder="0"
style="border: 1px solid #ddd; border-radius: 6px;">
</iframe>
```
**Rendu** :
```
┌─────────────────────────────────────┐
│ 📄 Security Policy 2025 │
│ 42 confirmations │
│ [Sign this document] │
└─────────────────────────────────────┘
```
### 3. oEmbed (Auto-discovery)
Pour les plateformes supportant oEmbed (Notion, Outline, Confluence, etc.).
#### Comment ça marche
1. Coller l'URL dans votre éditeur :
```
https://sign.company.com/?doc=policy_2025
```
2. L'éditeur détecte automatiquement via la balise meta :
```html
<link rel="alternate" type="application/json+oembed"
href="https://sign.company.com/oembed?url=..." />
```
3. L'éditeur appelle `/oembed` et reçoit :
```json
{
"type": "rich",
"version": "1.0",
"title": "Security Policy 2025 - 42 confirmations",
"provider_name": "Ackify",
"html": "<iframe src=\"https://sign.company.com/?doc=policy_2025\" ...>",
"height": 200
}
```
4. L'éditeur affiche l'iframe automatiquement
#### Plateformes Supportées
- ✅ **Notion** - Paste URL → Auto-embed
- ✅ **Outline** - Paste URL → Auto-embed
- ✅ **Confluence** - oEmbed macro
- ✅ **AppFlowy** - URL unfurling
- ✅ **Slack** - Link unfurling (Open Graph)
- ✅ **Microsoft Teams** - Card preview
- ✅ **Discord** - Rich embed
## Open Graph & Twitter Cards
Ackify génère automatiquement des meta tags pour les previews :
```html
<!-- Auto-généré pour /?doc=policy_2025 -->
<meta property="og:title" content="Security Policy 2025 - 42 confirmations" />
<meta property="og:description" content="42 personnes ont confirmé avoir lu le document" />
<meta property="og:url" content="https://sign.company.com/?doc=policy_2025" />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary" />
```
**Résultat dans Slack/Teams** :
```
┌─────────────────────────────────────┐
│ 🔐 Ackify │
│ Security Policy 2025 │
│ 42 confirmations │
│ sign.company.com │
└─────────────────────────────────────┘
```
## Intégrations Spécifiques
### Notion
1. Coller l'URL dans une page Notion :
```
https://sign.company.com/?doc=policy_2025
```
2. Notion détecte automatiquement l'oEmbed
3. Le widget apparaît avec bouton de signature
**Alternative** : Créer un embed manuel
- `/embed` → Paste URL
### Outline
1. Dans un document Outline, coller :
```
https://sign.company.com/?doc=policy_2025
```
2. Outline charge automatiquement le widget
### Google Docs
Google Docs ne supporte pas les iframes directement, mais :
1. **Option 1 - Lien** :
```
Veuillez signer : https://sign.company.com/?doc=policy_2025
```
2. **Option 2 - Image Badge** :
```
![Sign now](https://sign.company.com/badge/policy_2025.png)
```
3. **Option 3 - Google Sites** :
- Créer une page Google Sites
- Insérer l'iframe
- Lier depuis Google Docs
Voir [docs/integrations/google-doc/](../integrations/google-doc/) pour plus de détails.
### Confluence
1. Éditer une page Confluence
2. Insérer macro "oEmbed" ou "HTML Embed"
3. Coller :
```
https://sign.company.com/?doc=policy_2025
```
### Slack
**Link Unfurling** :
1. Poster l'URL dans un channel :
```
Hey team, please sign: https://sign.company.com/?doc=policy_2025
```
2. Slack affiche automatiquement une preview (Open Graph)
**Slash Command** (futur) :
```
/ackify sign policy_2025
```
### Microsoft Teams
1. Poster l'URL dans une conversation
2. Teams affiche une card preview (Open Graph)
## Badge PNG
Générer un badge visuel pour README, wiki, etc.
### URL du Badge
```
https://sign.company.com/badge/policy_2025.png
```
**Rendu** :
![Signature Status](https://img.shields.io/badge/Signatures-42%2F50-green)
### Markdown
```markdown
[![Sign this document](https://sign.company.com/badge/policy_2025.png)](https://sign.company.com/?doc=policy_2025)
```
### HTML
```html
<a href="https://sign.company.com/?doc=policy_2025">
<img src="https://sign.company.com/badge/policy_2025.png" alt="Signature status">
</a>
```
## API oEmbed
### Endpoint
```http
GET /oembed?url=https://sign.company.com/?doc=policy_2025
```
**Response** :
```json
{
"type": "rich",
"version": "1.0",
"title": "Document policy_2025 - 42 confirmations",
"provider_name": "Ackify",
"provider_url": "https://sign.company.com",
"html": "<iframe src=\"https://sign.company.com/?doc=policy_2025\" width=\"100%\" height=\"200\" frameborder=\"0\" style=\"border: 1px solid #ddd; border-radius: 6px;\" allowtransparency=\"true\"></iframe>",
"width": null,
"height": 200
}
```
### Paramètres
| Paramètre | Description | Exemple |
|-----------|-------------|---------|
| `url` | URL du document (obligatoire) | `?url=https://...` |
| `maxwidth` | Largeur max (optionnel) | `?maxwidth=800` |
| `maxheight` | Hauteur max (optionnel) | `?maxheight=300` |
### Discovery
Toutes les pages incluent la balise de discovery :
```html
<link rel="alternate"
type="application/json+oembed"
href="https://sign.company.com/oembed?url=..."
title="Document title" />
```
## Personnalisation
### Thème Dark Mode
Le widget détecte automatiquement le dark mode du navigateur :
```css
@media (prefers-color-scheme: dark) {
/* Thème sombre automatique */
}
```
### Taille Personnalisée
```html
<iframe src="https://sign.company.com/?doc=policy_2025"
width="800"
height="300"
frameborder="0">
</iframe>
```
### Langue
Le widget détecte automatiquement la langue du navigateur :
- `fr` - Français
- `en` - English
- `es` - Español
- `de` - Deutsch
- `it` - Italiano
## Sécurité
### iFrame Sandboxing
Par défaut, les iframes Ackify autorisent :
- `allow-same-origin` - Cookies OAuth2
- `allow-scripts` - Fonctionnalités Vue.js
- `allow-forms` - Soumission de signatures
- `allow-popups` - OAuth redirect
### CORS
Ackify configure automatiquement CORS pour :
- Toutes les origines (lecture publique)
- Credentials via `Access-Control-Allow-Credentials`
### CSP
Content Security Policy headers configurés pour permettre l'embedding :
```
X-Frame-Options: SAMEORIGIN
Content-Security-Policy: frame-ancestors 'self' https://notion.so https://outline.com
```
## Troubleshooting
### L'iframe ne s'affiche pas
Vérifier :
- HTTPS activé (required pour OAuth)
- CSP headers permettent l'embedding
- Pas de bloqueur de contenu (uBlock, Privacy Badger)
### oEmbed non détecté
Vérifier :
- La balise `<link rel="alternate" type="application/json+oembed">` est présente
- L'URL est exacte (avec `?doc=...`)
- La plateforme supporte oEmbed discovery
### Preview Slack vide
Vérifier :
- Open Graph meta tags présents
- URL publiquement accessible
- Pas de redirect infini
## Exemples Complets
Voir :
- [docs/integrations/google-doc/](../integrations/google-doc/) - Intégration Google Workspace
- Plus d'exemples à venir...

View File

@@ -0,0 +1,328 @@
# Expected Signers
Tracking des signataires attendus avec rappels email.
## Vue d'Ensemble
La feature "Expected Signers" permet de :
- Définir qui doit signer un document
- Tracker le taux de complétion
- Envoyer des rappels email automatiques
- Détecter les signatures inattendues
## Ajouter des Signataires
### Via le Dashboard Admin
1. Aller sur `/admin`
2. Sélectionner un document
3. Cliquer sur "Expected Signers"
4. Coller la liste d'emails :
```
Alice Smith <alice@company.com>
bob@company.com
charlie@company.com
```
**Formats supportés** :
- Un email par ligne
- Emails séparés par virgules
- Emails séparés par points-virgules
- Format avec nom : `Alice Smith <alice@company.com>`
### Via l'API
```http
POST /api/v1/admin/documents/policy_2025/signers
Content-Type: application/json
X-CSRF-Token: abc123
{
"email": "alice@company.com",
"name": "Alice Smith",
"notes": "Engineering team lead"
}
```
### Ajout en Batch
```bash
# Liste d'emails dans un fichier
cat emails.txt | while read email; do
curl -X POST http://localhost:8080/api/v1/admin/documents/policy_2025/signers \
-b cookies.txt \
-H "X-CSRF-Token: $CSRF_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"email\": \"$email\"}"
done
```
## Tracking de Complétion
### Dashboard Admin
Affiche :
- **Barre de progression** - Visuelle avec pourcentage
- **Liste des signataires** :
- ✓ Email (signé le DD/MM/YYYY HH:MM)
- ⏳ Email (en attente)
- **Statistiques** :
- Expected: 50
- Signed: 42
- Pending: 8
- Completion: 84%
### Via l'API
```http
GET /api/v1/documents/policy_2025/expected-signers
```
**Response** :
```json
{
"docId": "policy_2025",
"expectedSigners": [
{
"email": "alice@company.com",
"name": "Alice Smith",
"addedAt": "2025-01-15T10:00:00Z",
"hasSigned": true,
"signedAt": "2025-01-15T14:30:00Z"
},
{
"email": "bob@company.com",
"name": "Bob Jones",
"addedAt": "2025-01-15T10:00:00Z",
"hasSigned": false
}
],
"completionStats": {
"expected": 50,
"signed": 42,
"pending": 8,
"completionPercentage": 84.0
}
}
```
## Rappels Email
### Envoyer des Rappels
**Via le Dashboard** :
1. Sélectionner les destinataires (ou "Select all pending")
2. Choisir la langue (fr, en, es, de, it)
3. Cliquer "Send Reminders"
**Via l'API** :
```http
POST /api/v1/admin/documents/policy_2025/reminders
Content-Type: application/json
X-CSRF-Token: abc123
{
"emails": ["bob@company.com", "charlie@company.com"],
"locale": "fr"
}
```
**Response** :
```json
{
"sent": 2,
"failed": 0,
"errors": []
}
```
### Contenu de l'Email
Les templates sont dans `/backend/templates/emails/{locale}/reminder.html` :
```html
Bonjour {{.RecipientName}},
Vous êtes attendu(e) pour signer le document "{{.DocumentTitle}}".
[Bouton: Signer maintenant] → {{.SignURL}}
Document disponible ici : {{.DocumentURL}}
Cordialement,
{{.OrganisationName}}
```
**Variables disponibles** :
- `RecipientName` - Nom du destinataire
- `DocumentTitle` - Titre du document
- `DocumentURL` - URL du document (metadata)
- `SignURL` - Lien direct vers la page de signature
- `OrganisationName` - Nom de votre organisation
### Historique des Rappels
```http
GET /api/v1/admin/documents/policy_2025/reminders
```
**Response** :
```json
{
"reminders": [
{
"recipientEmail": "bob@company.com",
"sentAt": "2025-01-15T15:00:00Z",
"sentBy": "admin@company.com",
"status": "sent",
"templateUsed": "reminder"
},
{
"recipientEmail": "charlie@company.com",
"sentAt": "2025-01-15T15:00:05Z",
"sentBy": "admin@company.com",
"status": "failed",
"errorMessage": "SMTP timeout"
}
]
}
```
**Statuts** :
- `sent` - Envoyé avec succès
- `failed` - Échec d'envoi
- `bounced` - Email invalide (bounce)
## Signatures Inattendues
Détecte automatiquement les utilisateurs qui ont signé **sans être attendus**.
### Via le Dashboard
Section "Unexpected Signatures" affiche :
```
⚠️ 3 signatures inattendues détectées
- stranger@external.com (signé le 15/01/2025)
- unknown@gmail.com (signé le 16/01/2025)
```
### Via l'API
Requête SQL pour détecter :
```sql
SELECT s.user_email, s.signed_at
FROM signatures s
LEFT JOIN expected_signers e ON s.user_email = e.email AND s.doc_id = e.doc_id
WHERE s.doc_id = 'policy_2025' AND e.id IS NULL;
```
## Cas d'Usage
### Formation Obligatoire
```
Document: "GDPR Training 2025"
Expected: Tous les employés (CSV import)
```
**Workflow** :
1. Import CSV avec emails employés
2. Envoi du lien de signature à tous
3. Rappel automatique J+7 aux non-signants
4. Export final pour RH
### Politique de Sécurité
```
Document: "Security Policy v3"
Expected: Engineers + DevOps (50 personnes)
```
**Features utilisées** :
- Tracking temps réel (tableau de bord)
- Rappels sélectifs (seulement certains)
- Métadonnées document (URL + checksum)
### Contractuel
```
Document: "NDA 2025"
Expected: Prestataires externes (liste manuelle)
```
**Particularité** :
- Domaine OAuth restreint désactivé
- Permet aux emails externes de signer
- Detection des signatures inattendues cruciale
## Retirer un Signataire
```http
DELETE /api/v1/admin/documents/policy_2025/signers/alice@company.com
X-CSRF-Token: abc123
```
**Comportement** :
- Retire de la liste expected_signers
- La signature (si existante) reste en base
- Le taux de complétion est recalculé
## Configuration Email
Pour que les rappels fonctionnent, configurer SMTP :
```bash
ACKIFY_MAIL_HOST=smtp.gmail.com
ACKIFY_MAIL_PORT=587
ACKIFY_MAIL_USERNAME=noreply@company.com
ACKIFY_MAIL_PASSWORD=app_password
ACKIFY_MAIL_FROM=noreply@company.com
```
Voir [Email Setup](../configuration/email-setup.md) pour plus de détails.
## Bonnes Pratiques
### Import CSV
Pour importer massivement :
```python
import csv
import requests
with open('employees.csv') as f:
reader = csv.DictReader(f)
for row in reader:
requests.post(
'http://localhost:8080/api/v1/admin/documents/policy_2025/signers',
json={'email': row['email'], 'name': row['name']},
headers={'X-CSRF-Token': csrf_token},
cookies=cookies
)
```
### Personnalisation
Pour des rappels plus personnalisés :
1. Modifier les templates dans `/backend/templates/emails/`
2. Ajouter des variables custom dans le service email
3. Rebuild l'image Docker
### Monitoring
Surveiller les `reminder_logs` pour détecter :
- Taux de bounce élevé (emails invalides)
- Échecs SMTP répétés
- Efficacité des rappels (taux de conversion)
## Limitations
- Maximum **1000 expected signers** par document (soft limit)
- Rappels envoyés **synchrones** (pas de queue)
- Pas de rappels automatiques planifiés (manuel uniquement)
## API Reference
Voir [API Documentation](../api.md#expected-signers-admin) pour tous les endpoints.

320
docs/fr/features/i18n.md Normal file
View File

@@ -0,0 +1,320 @@
# Internationalisation (i18n)
Support multilingue complet du frontend Ackify.
## Langues Supportées
- 🇫🇷 **Français** (par défaut)
- 🇬🇧 **English** (fallback)
- 🇪🇸 **Español**
- 🇩🇪 **Deutsch**
- 🇮🇹 **Italiano**
## Frontend (Vue.js)
### Sélection de Langue
Le frontend détecte automatiquement la langue via :
1. **localStorage** - Choix utilisateur sauvegardé
2. **navigator.language** - Langue du navigateur
3. **Fallback** - English si non supportée
### Switcher de Langue
Interface utilisateur avec drapeaux Unicode :
```
🇫🇷 FR | 🇬🇧 EN | 🇪🇸 ES | 🇩🇪 DE | 🇮🇹 IT
```
Clic → Langue change + sauvegarde dans localStorage
### Fichiers de Traduction
Localisés dans `/webapp/src/locales/` :
```
locales/
├── fr.json # Français
├── en.json # English
├── es.json # Español
├── de.json # Deutsch
└── it.json # Italiano
```
### Structure JSON
```json
{
"home": {
"title": "Ackify - Proof of Read",
"subtitle": "Signatures cryptographiques de lecture"
},
"document": {
"sign": "Signer ce document",
"signed": "Document signé",
"signatures": "{count} confirmation | {count} confirmations"
}
}
```
**Pluralisation** :
```json
"signatures": "{count} confirmation | {count} confirmations"
```
Usage :
```vue
{{ $t('document.signatures', { count: 42 }) }}
// → "42 confirmations"
```
## Backend (Go)
### Templates Email
Les emails utilisent des templates multilingues dans `/backend/templates/emails/` :
```
templates/emails/
├── fr/
│ ├── reminder.html
│ └── reminder.txt
├── en/
│ ├── reminder.html
│ └── reminder.txt
├── es/...
├── de/...
└── it/...
```
### Envoi avec Locale
```http
POST /api/v1/admin/documents/doc_id/reminders
Content-Type: application/json
{
"emails": ["user@company.com"],
"locale": "fr"
}
```
Le backend charge le template `fr/reminder.html`.
### Configuration
```bash
# Langue par défaut pour les emails (défaut: en)
ACKIFY_MAIL_DEFAULT_LOCALE=fr
```
## Ajouter une Langue
### Frontend
1. **Créer le fichier de traduction** :
```bash
cd webapp/src/locales
cp en.json pt.json # Portugais
```
2. **Traduire** :
```json
{
"home": {
"title": "Ackify - Prova de Leitura",
"subtitle": "Assinaturas criptográficas de leitura"
}
}
```
3. **Enregistrer dans i18n** :
```typescript
// webapp/src/i18n.ts
import pt from './locales/pt.json'
const i18n = createI18n({
locale: 'fr',
fallbackLocale: 'en',
messages: {
fr, en, es, de, it,
pt // Ajouter ici
}
})
```
4. **Ajouter au sélecteur** :
```vue
<!-- components/LanguageSwitcher.vue -->
<button @click="changeLocale('pt')">🇵🇹 PT</button>
```
### Backend
1. **Créer le répertoire** :
```bash
mkdir -p backend/templates/emails/pt
```
2. **Créer les templates** :
```bash
cp backend/templates/emails/en/reminder.html backend/templates/emails/pt/
cp backend/templates/emails/en/reminder.txt backend/templates/emails/pt/
```
3. **Traduire les templates**
4. **Rebuild** :
```bash
docker compose up -d --force-recreate ackify-ce --build
```
## Vérification i18n
### Script de Validation
Le projet inclut un script pour vérifier la complétude des traductions :
```bash
cd webapp
npm run lint:i18n
```
**Output** :
```
✅ fr.json - 156 keys
✅ en.json - 156 keys
✅ es.json - 156 keys
✅ de.json - 156 keys
✅ it.json - 156 keys
All translations are complete!
```
### CI/CD
Le script s'exécute automatiquement dans GitHub Actions pour bloquer les PRs avec traductions manquantes.
## Bonnes Pratiques
### Clés de Traduction
- ✅ Utiliser des clés structurées : `feature.action.label`
- ✅ Grouper par page/composant
- ✅ Éviter les clés trop génériques (`button`, `title`)
- ✅ Utiliser des placeholders : `{count}`, `{name}`
**Exemple** :
```json
{
"admin": {
"documents": {
"list": {
"title": "Liste des documents",
"count": "{count} document | {count} documents"
}
}
}
}
```
### Synchronisation
Lors de l'ajout de nouvelles clés en français :
```bash
# Sync script (à créer)
node scripts/sync-i18n-from-fr.js
```
Copie automatiquement les nouvelles clés vers les autres langues avec `[TODO]`.
### Textes Longs
Pour les textes longs, utiliser des arrays :
```json
{
"help": {
"intro": [
"Ackify permet de créer des signatures cryptographiques.",
"Chaque signature est horodatée et non-répudiable.",
"Les données sont stockées de manière immuable."
]
}
}
```
Usage :
```vue
<p v-for="line in $tm('help.intro')" :key="line">
{{ line }}
</p>
```
## Formats Spécifiques
### Dates
```typescript
// Formater avec la locale courante
import { useI18n } from 'vue-i18n'
const { locale } = useI18n()
const formatted = new Date().toLocaleDateString(locale.value, {
year: 'numeric',
month: 'long',
day: 'numeric'
})
// fr: "15 janvier 2025"
// en: "January 15, 2025"
```
### Nombres
```typescript
const formatted = (42000).toLocaleString(locale.value)
// fr: "42 000"
// en: "42,000"
```
## SEO & Meta Tags
Les meta tags sont traduits dynamiquement :
```vue
<script setup>
import { useI18n } from 'vue-i18n'
import { useHead } from '@vueuse/head'
const { t } = useI18n()
useHead({
title: t('home.title'),
meta: [
{ name: 'description', content: t('home.description') }
]
})
</script>
```
## Documentation Complète
Pour plus de détails sur l'implémentation i18n du frontend, voir :
**[webapp/I18N.md](../../webapp/I18N.md)**
Ce fichier contient :
- Architecture complète vue-i18n
- Guide de contribution
- Scripts de synchronisation
- Exemples avancés

View File

@@ -0,0 +1,235 @@
# Signatures Cryptographiques
Flow complet de signature avec Ed25519 et garanties de sécurité.
## Principe
Ackify utilise **Ed25519** (courbe elliptique) pour créer des signatures cryptographiques non-répudiables.
**Garanties** :
-**Non-répudiation** - La signature prouve l'identité du signataire
-**Intégrité** - Le hash SHA-256 détecte toute modification
-**Horodatage immutable** - Triggers PostgreSQL empêchent la backdating
-**Unicité** - Une seule signature par utilisateur/document
## Flow de Signature
### 1. Utilisateur accède au document
```
https://sign.company.com/?doc=policy_2025
```
Le frontend Vue.js charge et affiche :
- Titre du document (si metadata existe)
- Nombre de signatures existantes
- Bouton "Sign this document"
### 2. Vérification de session
Le frontend appelle :
```http
GET /api/v1/users/me
```
**Si non connecté** → Redirection OAuth2
**Si connecté** → Affichage du bouton de signature
### 3. Signature
Au clic sur "Sign", le frontend :
1. Obtient un token CSRF :
```http
GET /api/v1/csrf
```
2. Envoie la signature :
```http
POST /api/v1/signatures
Content-Type: application/json
X-CSRF-Token: abc123
{
"doc_id": "policy_2025"
}
```
### 4. Backend Processing
Le backend (Go) :
1. **Vérifie la session** - Utilisateur authentifié
2. **Génère la signature Ed25519** :
```go
payload := fmt.Sprintf("%s:%s:%s:%s", docID, userSub, userEmail, timestamp)
hash := sha256.Sum256([]byte(payload))
signature := ed25519.Sign(privateKey, hash[:])
```
3. **Calcule prev_hash** - Hash de la dernière signature (chaînage)
4. **Insère en base** :
```sql
INSERT INTO signatures (doc_id, user_sub, user_email, signed_at, payload_hash, signature, nonce, prev_hash)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
```
5. **Retourne la signature** au frontend
### 5. Confirmation
Le frontend affiche :
- ✅ Signature confirmée
- Horodatage
- Lien vers la liste des signatures
## Structure de la Signature
```json
{
"docId": "policy_2025",
"userEmail": "alice@company.com",
"userName": "Alice Smith",
"signedAt": "2025-01-15T14:30:00Z",
"payloadHash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"signature": "ed25519:3045022100...",
"nonce": "abc123xyz",
"prevHash": "sha256:prev..."
}
```
**Champs** :
- `payloadHash` - SHA-256 du payload (doc_id:user_sub:email:timestamp)
- `signature` - Signature Ed25519 en base64
- `nonce` - Protection anti-replay
- `prevHash` - Hash de la signature précédente (blockchain-like)
## Vérification de Signature
### Manuelle (via API)
```http
GET /api/v1/documents/policy_2025/signatures
```
Retourne toutes les signatures avec :
- Email signataire
- Horodatage
- Hash + signature
### Programmation (Go)
```go
import "crypto/ed25519"
func VerifySignature(publicKey ed25519.PublicKey, payload, signature []byte) bool {
hash := sha256.Sum256(payload)
return ed25519.Verify(publicKey, hash[:], signature)
}
```
## Contraintes PostgreSQL
### Une signature par user/document
```sql
UNIQUE (doc_id, user_sub)
```
**Comportement** :
- Si l'utilisateur tente de signer 2 fois → Erreur 409 Conflict
- Le frontend détecte cela et affiche "Already signed"
### Immutabilité de `created_at`
Trigger PostgreSQL :
```sql
CREATE TRIGGER prevent_signatures_created_at_update
BEFORE UPDATE ON signatures
FOR EACH ROW
EXECUTE FUNCTION prevent_created_at_update();
```
**Garantie** : Impossible de backdater une signature.
## Chaînage (Blockchain-like)
Chaque signature référence la précédente via `prev_hash` :
```
Signature 1 → hash1
Signature 2 → hash2 (prev_hash = hash1)
Signature 3 → hash3 (prev_hash = hash2)
```
**Détection de tampering** :
- Si une signature est modifiée, le `prev_hash` de la suivante ne correspond plus
- Permet de détecter toute modification de l'historique
## Sécurité
### Clé Privée Ed25519
Générée automatiquement au premier démarrage ou via :
```bash
ACKIFY_ED25519_PRIVATE_KEY=$(openssl rand -base64 64)
```
**Important** :
- La clé privée ne quitte jamais le serveur
- Stockée en mémoire uniquement (pas en base)
- Backup requis si vous voulez garder la même clé après redéploiement
### Protection Anti-Replay
Le `nonce` unique empêche la réutilisation d'une signature :
```go
nonce := fmt.Sprintf("%s-%d", userSub, time.Now().UnixNano())
```
### Rate Limiting
Les signatures sont limitées à **100 requêtes/minute** par IP.
## Cas d'Usage
### Validation de lecture de politique
```
Document: "Security Policy 2025"
URL: https://sign.company.com/?doc=security_policy_2025
```
**Workflow** :
1. Admin envoie le lien aux employés
2. Chaque employé clique, lit, et signe
3. Admin voit la completion dans `/admin`
### Accusé de réception formation
```
Document: "GDPR Training 2025"
Expected signers: 50 employés
```
**Features** :
- Tracking de complétion (42/50 = 84%)
- Rappels email automatiques
- Export des signatures
### Acknowledgment contractuel
```
Document: "Terms of Service v3"
Checksum: SHA-256 du PDF
```
**Vérification** :
- Utilisateur calcule le checksum du PDF
- Compare avec la metadata stockée
- Signe si identique
Voir [Checksums](checksums.md) pour plus de détails.
## API Reference
Voir [API Documentation](../api.md) pour tous les endpoints liés aux signatures.

208
docs/fr/getting-started.md Normal file
View File

@@ -0,0 +1,208 @@
# Getting Started
Guide d'installation et de configuration d'Ackify avec Docker Compose.
## Prérequis
- Docker et Docker Compose installés
- Un domaine (ou localhost pour les tests)
- Credentials OAuth2 (Google, GitHub, GitLab, ou custom)
## Installation Rapide
### 1. Cloner le dépôt
```bash
git clone https://github.com/btouchard/ackify-ce.git
cd ackify-ce
```
### 2. Configuration
Copier le fichier d'exemple et l'éditer :
```bash
cp .env.example .env
nano .env
```
**Variables obligatoires minimales** :
```bash
# Domaine public de votre instance
APP_DNS=sign.your-domain.com
ACKIFY_BASE_URL=https://sign.your-domain.com
ACKIFY_ORGANISATION="Your Organization Name"
# Base de données PostgreSQL
POSTGRES_USER=ackifyr
POSTGRES_PASSWORD=your_secure_password_here
POSTGRES_DB=ackify
# OAuth2 (exemple avec Google)
ACKIFY_OAUTH_PROVIDER=google
ACKIFY_OAUTH_CLIENT_ID=your_google_client_id
ACKIFY_OAUTH_CLIENT_SECRET=your_google_client_secret
# Sécurité - générer avec: openssl rand -base64 32
ACKIFY_OAUTH_COOKIE_SECRET=your_base64_encoded_secret_key
```
### 3. Démarrage
```bash
docker compose up -d
```
Cette commande va :
- Télécharger les images Docker nécessaires
- Démarrer PostgreSQL avec healthcheck
- Appliquer les migrations de base de données
- Lancer l'application Ackify
### 4. Vérification
```bash
# Voir les logs
docker compose logs -f ackify-ce
# Vérifier le health check
curl http://localhost:8080/api/v1/health
# Attendu: {"status":"healthy","database":"connected"}
```
### 5. Accès à l'interface
Ouvrir votre navigateur :
- **Interface publique** : http://localhost:8080
- **Admin dashboard** : http://localhost:8080/admin (nécessite email dans ACKIFY_ADMIN_EMAILS)
## Configuration OAuth2
Avant de pouvoir utiliser Ackify, configurez votre provider OAuth2.
### Google OAuth2
1. Aller sur [Google Cloud Console](https://console.cloud.google.com/)
2. Créer un nouveau projet ou sélectionner un projet existant
3. Activer l'API "Google+ API"
4. Créer des credentials OAuth 2.0 :
- Type : Web application
- Authorized redirect URIs : `https://sign.your-domain.com/api/v1/auth/callback`
5. Copier le Client ID et Client Secret dans `.env`
```bash
ACKIFY_OAUTH_PROVIDER=google
ACKIFY_OAUTH_CLIENT_ID=123456789-abc.apps.googleusercontent.com
ACKIFY_OAUTH_CLIENT_SECRET=GOCSPX-xyz...
```
### GitHub OAuth2
1. Aller sur [GitHub Developer Settings](https://github.com/settings/developers)
2. Créer une nouvelle OAuth App
3. Configuration :
- Homepage URL : `https://sign.your-domain.com`
- Callback URL : `https://sign.your-domain.com/api/v1/auth/callback`
4. Générer un client secret
```bash
ACKIFY_OAUTH_PROVIDER=github
ACKIFY_OAUTH_CLIENT_ID=Iv1.abc123
ACKIFY_OAUTH_CLIENT_SECRET=ghp_xyz...
```
Voir [OAuth Providers](configuration/oauth-providers.md) pour GitLab et custom providers.
## Génération des Secrets
```bash
# Cookie secret (obligatoire)
openssl rand -base64 32
# Ed25519 private key (optionnel, auto-généré si absent)
openssl rand -base64 64
```
## Premiers Pas
### Créer votre première signature
1. Accéder à `http://localhost:8080/?doc=test_document`
2. Cliquer sur "Sign this document"
3. Se connecter via OAuth2
4. Valider la signature
### Accéder au dashboard admin
1. Ajouter votre email dans `.env` :
```bash
ACKIFY_ADMIN_EMAILS=admin@company.com
```
2. Redémarrer :
```bash
docker compose restart ackify-ce
```
3. Se connecter puis accéder à `/admin`
### Intégrer dans une page
```html
<!-- Widget embeddable -->
<iframe src="https://sign.your-domain.com/?doc=test_document"
width="600" height="200"
frameborder="0"
style="border: 1px solid #ddd; border-radius: 6px;"></iframe>
```
## Commandes Utiles
```bash
# Voir les logs
docker compose logs -f ackify-ce
# Redémarrer
docker compose restart ackify-ce
# Arrêter
docker compose down
# Reconstruire après modifications
docker compose up -d --force-recreate ackify-ce --build
# Accéder à la base de données
docker compose exec ackify-db psql -U ackifyr -d ackify
```
## Troubleshooting
### L'application ne démarre pas
```bash
# Vérifier les logs
docker compose logs ackify-ce
# Vérifier la santé de PostgreSQL
docker compose ps ackify-db
```
### Erreur de migration
```bash
# Relancer les migrations manuellement
docker compose up ackify-migrate
```
### OAuth callback error
Vérifier que :
- `ACKIFY_BASE_URL` correspond exactement à votre domaine
- La callback URL dans le provider OAuth2 est correcte
- Le cookie secret est bien configuré
## Next Steps
- [Configuration complète](configuration.md)
- [Déploiement en production](deployment.md)
- [Configuration des fonctionnalités](features/)
- [API Reference](api.md)

View File

@@ -1,6 +1,4 @@
# Application Configuration
APP_NAME=ackify-ce
APP_DNS=your-domain.com
APP_BASE_URL=https://your-domain.com
APP_ORGANISATION="Your Organization Name"

View File

@@ -2,23 +2,51 @@
name: ackify-ce
services:
ackify-migrate:
image: btouchard/ackify-ce:latest
container_name: ackify-migrate
environment:
ACKIFY_DB_DSN: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@ackify-db:5432/${POSTGRES_DB}?sslmode=disable"
depends_on:
ackify-db:
condition: service_healthy
networks:
- internal
command: ["/app/migrate", "up"]
entrypoint: []
restart: "no"
ackify-ce:
image: btouchard/ackify-ce:latest
container_name: ackify-ce
restart: unless-stopped
environment:
ACKIFY_BASE_URL: "https://${APP_DNS}"
ACKIFY_LOG_LEVEL: "${ACKIFY_LOG_LEVEL}"
ACKIFY_BASE_URL: "${ACKIFY_BASE_URL}"
ACKIFY_ORGANISATION: "${ACKIFY_ORGANISATION}"
ACKIFY_ADMIN_EMAILS: "${ACKIFY_ADMIN_EMAILS}"
ACKIFY_LISTEN_ADDR: ":8080"
ACKIFY_DB_DSN: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@ackify-db:5432/${POSTGRES_DB}?sslmode=disable"
ACKIFY_OAUTH_PROVIDER: "${ACKIFY_OAUTH_PROVIDER}"
ACKIFY_OAUTH_CLIENT_ID: "${ACKIFY_OAUTH_CLIENT_ID}"
ACKIFY_OAUTH_CLIENT_SECRET: "${ACKIFY_OAUTH_CLIENT_SECRET}"
ACKIFY_OAUTH_ALLOWED_DOMAIN: "${ACKIFY_OAUTH_ALLOWED_DOMAIN}"
ACKIFY_OAUTH_AUTH_URL: "${ACKIFY_OAUTH_AUTH_URL:-}"
ACKIFY_OAUTH_TOKEN_URL: "${ACKIFY_OAUTH_TOKEN_URL:-}"
ACKIFY_OAUTH_USERINFO_URL: "${ACKIFY_OAUTH_USERINFO_URL:-}"
ACKIFY_OAUTH_LOGOUT_URL: "${ACKIFY_OAUTH_LOGOUT_URL:-}"
ACKIFY_OAUTH_ALLOWED_DOMAIN: "${ACKIFY_OAUTH_ALLOWED_DOMAIN:-}"
ACKIFY_OAUTH_COOKIE_SECRET: "${ACKIFY_OAUTH_COOKIE_SECRET}"
ACKIFY_DB_DSN: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@ackify_db:5432/${POSTGRES_DB}?sslmode=disable"
ACKIFY_ED25519_PRIVATE_KEY: "${ACKIFY_ED25519_PRIVATE_KEY}"
ACKIFY_LISTEN_ADDR: ":8080"
ACKIFY_MAIL_HOST: "${ACKIFY_MAIL_HOST:-}"
ACKIFY_MAIL_PORT: "${ACKIFY_MAIL_PORT:-}"
ACKIFY_MAIL_TLS: "false"
ACKIFY_MAIL_STARTTLS: "false"
ACKIFY_MAIL_FROM: "${ACKIFY_MAIL_FROM:-}"
ACKIFY_MAIL_FROM_NAME: "${ACKIFY_MAIL_FROM_NAME:-}"
depends_on:
ackify_db:
ackify-migrate:
condition: service_completed_successfully
ackify-db:
condition: service_healthy
networks:
- internal

View File

@@ -282,6 +282,24 @@
"home": "Zurück zur Startseite"
},
"footer": {
"description": "Open-Source-Lösung für kryptographische Dokumentenlesebestätigung mit unwiderruflichen Ed25519-Signaturen.",
"navigation": {
"title": "Navigation"
},
"resources": {
"title": "Ressourcen",
"documentation": "Dokumentation",
"apiReference": "API-Referenz",
"support": "Support"
},
"legal": {
"title": "Rechtliches",
"terms": "Nutzungsbedingungen",
"privacy": "Datenschutzerklärung",
"contact": "Kontakt"
},
"copyright": "Alle Rechte vorbehalten.",
"license": "Lizenziert unter AGPL-3.0-or-later",
"madeWith": "Erstellt mit",
"by": "von",
"links": {

View File

@@ -282,6 +282,24 @@
"home": "Back to home"
},
"footer": {
"description": "Open-source cryptographic document reading confirmation solution with non-repudiable Ed25519 signatures.",
"navigation": {
"title": "Navigation"
},
"resources": {
"title": "Resources",
"documentation": "Documentation",
"apiReference": "API Reference",
"support": "Support"
},
"legal": {
"title": "Legal",
"terms": "Terms of Service",
"privacy": "Privacy Policy",
"contact": "Contact"
},
"copyright": "All rights reserved.",
"license": "Licensed under AGPL-3.0-or-later",
"madeWith": "Made with",
"by": "by",
"links": {

View File

@@ -282,6 +282,24 @@
"home": "Volver al inicio"
},
"footer": {
"description": "Solución de código abierto de confirmación criptográfica de lectura de documentos con firmas Ed25519 no repudiables.",
"navigation": {
"title": "Navegación"
},
"resources": {
"title": "Recursos",
"documentation": "Documentación",
"apiReference": "Referencia API",
"support": "Soporte"
},
"legal": {
"title": "Legal",
"terms": "Términos de uso",
"privacy": "Política de privacidad",
"contact": "Contacto"
},
"copyright": "Todos los derechos reservados.",
"license": "Licencia AGPL-3.0-or-later",
"madeWith": "Hecho con",
"by": "por",
"links": {

View File

@@ -282,6 +282,24 @@
"home": "Retour à l'accueil"
},
"footer": {
"description": "Solution open-source de confirmation cryptographique de lecture de documents avec signatures Ed25519 non répudiables.",
"navigation": {
"title": "Navigation"
},
"resources": {
"title": "Ressources",
"documentation": "Documentation",
"apiReference": "Référence API",
"support": "Support"
},
"legal": {
"title": "Légal",
"terms": "Conditions d'utilisation",
"privacy": "Politique de confidentialité",
"contact": "Contact"
},
"copyright": "Tous droits réservés.",
"license": "Licence AGPL-3.0-or-later",
"madeWith": "Fait avec",
"by": "par",
"links": {

View File

@@ -282,6 +282,24 @@
"home": "Torna alla home"
},
"footer": {
"description": "Soluzione open-source di conferma crittografica di lettura di documenti con firme Ed25519 non ripudiabili.",
"navigation": {
"title": "Navigazione"
},
"resources": {
"title": "Risorse",
"documentation": "Documentazione",
"apiReference": "Riferimento API",
"support": "Supporto"
},
"legal": {
"title": "Legale",
"terms": "Termini di utilizzo",
"privacy": "Politica sulla privacy",
"contact": "Contatto"
},
"copyright": "Tutti i diritti riservati.",
"license": "Licenza AGPL-3.0-or-later",
"madeWith": "Fatto con",
"by": "da",
"links": {