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
+1
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
+159 -1044
View File
File diff suppressed because it is too large Load Diff
+159 -1044
View File
File diff suppressed because it is too large Load Diff
-1672
View File
File diff suppressed because it is too large Load Diff
@@ -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
}
+201 -12
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
@@ -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)
@@ -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")
}
}
@@ -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
}
@@ -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")
}
})
}
@@ -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,
@@ -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)
+31 -3
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)
}
@@ -0,0 +1,4 @@
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Drop oauth_sessions table
DROP TABLE IF EXISTS oauth_sessions CASCADE;
@@ -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
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
+90
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
}
+257
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)
}
}
+58
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)
}
+187
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)
}
}
+48 -26
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
+12 -13
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
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
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
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.
+353
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.
+254
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
```
+235
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
+339
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...
+328
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
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
+235
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
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
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
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
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.
+353
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.
+254
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
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
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
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)
+235
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
+339
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...
+328
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
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
+235
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
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)
-2
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"
@@ -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
+18
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": {
+18
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": {
+18
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": {
+18
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": {
+18
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": {