Production hardening: security, resilience, observability, and compliance

Password complexity: custom validator requiring uppercase, lowercase, digit (min 8 chars)
Token expiry: 90-day token lifetime with refresh endpoint (60-90 day renewal window)
Health check: /api/health/ now pings Postgres + Redis, returns 503 on failure
Audit logging: async audit_log table for auth events (login, register, delete, etc.)
Circuit breaker: APNs/FCM push sends wrapped with 5-failure threshold, 30s recovery
FK indexes: 27 missing foreign key indexes across all tables (migration 017)
CSP header: default-src 'none'; frame-ancestors 'none'
Gzip compression: level 5 with media endpoint skipper
Prometheus metrics: /metrics endpoint using existing monitoring service
External timeouts: 15s push, 30s SMTP, context timeouts on all external calls

Migrations: 016 (token created_at), 017 (FK indexes), 018 (audit_log)
Tests: circuit breaker (15), audit service (8), token refresh (7), health (4),
       middleware expiry (5), validator (new)
This commit is contained in:
Trey T
2026-03-26 14:05:28 -05:00
parent 4abc57535e
commit b679f28e55
30 changed files with 2077 additions and 47 deletions

View File

@@ -0,0 +1,93 @@
package services
import (
"sync"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/models"
)
// Audit event type constants
const (
AuditEventLogin = "auth.login"
AuditEventLoginFailed = "auth.login_failed"
AuditEventRegister = "auth.register"
AuditEventLogout = "auth.logout"
AuditEventPasswordReset = "auth.password_reset"
AuditEventPasswordChanged = "auth.password_changed"
AuditEventAccountDeleted = "auth.account_deleted"
)
// AuditService handles audit logging for security-relevant events.
// It writes audit log entries asynchronously via a buffered channel to avoid
// blocking request handlers.
type AuditService struct {
db *gorm.DB
logChan chan *models.AuditLog
done chan struct{}
stopOnce sync.Once
}
// NewAuditService creates a new audit service with a buffered channel for async writes.
// Call Stop() when shutting down to flush remaining entries.
func NewAuditService(db *gorm.DB) *AuditService {
s := &AuditService{
db: db,
logChan: make(chan *models.AuditLog, 256),
done: make(chan struct{}),
}
go s.processLogs()
return s
}
// processLogs drains the log channel and writes entries to the database.
func (s *AuditService) processLogs() {
defer close(s.done)
for entry := range s.logChan {
if err := s.db.Create(entry).Error; err != nil {
log.Error().Err(err).
Str("event_type", entry.EventType).
Msg("Failed to write audit log entry")
}
}
}
// Stop closes the log channel and waits for all pending entries to be written.
// It is safe to call Stop multiple times.
func (s *AuditService) Stop() {
s.stopOnce.Do(func() {
close(s.logChan)
})
<-s.done
}
// LogEvent records an audit event. It extracts the client IP and User-Agent from
// the Echo context and sends the entry to the background writer. If the channel
// is full the entry is dropped and an error is logged (non-blocking).
func (s *AuditService) LogEvent(c echo.Context, userID *uint, eventType string, details map[string]interface{}) {
var ip, ua string
if c != nil {
ip = c.RealIP()
ua = c.Request().Header.Get("User-Agent")
}
entry := &models.AuditLog{
UserID: userID,
EventType: eventType,
IPAddress: ip,
UserAgent: ua,
Details: models.JSONMap(details),
}
select {
case s.logChan <- entry:
// sent
default:
log.Warn().
Str("event_type", eventType).
Msg("Audit log channel full, dropping entry")
}
}

View File

@@ -0,0 +1,176 @@
package services
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/testutil"
)
func TestAuditService_LogEvent_WritesToDatabase(t *testing.T) {
db := testutil.SetupTestDB(t)
svc := NewAuditService(db)
defer svc.Stop()
// Create a fake Echo context
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login/", nil)
req.Header.Set("User-Agent", "TestAgent/1.0")
req.RemoteAddr = "192.168.1.1:12345"
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
userID := uint(42)
svc.LogEvent(c, &userID, AuditEventLogin, map[string]interface{}{
"method": "password",
})
// Stop flushes the channel
svc.Stop()
var entries []models.AuditLog
err := db.Find(&entries).Error
require.NoError(t, err)
require.Len(t, entries, 1)
entry := entries[0]
assert.Equal(t, uint(42), *entry.UserID)
assert.Equal(t, AuditEventLogin, entry.EventType)
assert.Equal(t, "TestAgent/1.0", entry.UserAgent)
assert.NotEmpty(t, entry.IPAddress)
assert.Equal(t, "password", entry.Details["method"])
assert.False(t, entry.CreatedAt.IsZero())
}
func TestAuditService_LogEvent_NilUserID(t *testing.T) {
db := testutil.SetupTestDB(t)
svc := NewAuditService(db)
defer svc.Stop()
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
svc.LogEvent(c, nil, AuditEventLoginFailed, map[string]interface{}{
"identifier": "unknown@test.com",
})
svc.Stop()
var entries []models.AuditLog
err := db.Find(&entries).Error
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Nil(t, entries[0].UserID)
assert.Equal(t, AuditEventLoginFailed, entries[0].EventType)
}
func TestAuditService_LogEvent_NilContext(t *testing.T) {
db := testutil.SetupTestDB(t)
svc := NewAuditService(db)
defer svc.Stop()
userID := uint(1)
svc.LogEvent(nil, &userID, AuditEventLogout, nil)
svc.Stop()
var entries []models.AuditLog
err := db.Find(&entries).Error
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, AuditEventLogout, entries[0].EventType)
assert.Empty(t, entries[0].IPAddress)
assert.Empty(t, entries[0].UserAgent)
}
func TestAuditService_LogEvent_MultipleEvents(t *testing.T) {
db := testutil.SetupTestDB(t)
svc := NewAuditService(db)
defer svc.Stop()
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
userID := uint(10)
svc.LogEvent(c, &userID, AuditEventRegister, nil)
svc.LogEvent(c, &userID, AuditEventLogin, nil)
svc.LogEvent(c, &userID, AuditEventLogout, nil)
svc.Stop()
var count int64
err := db.Model(&models.AuditLog{}).Count(&count).Error
require.NoError(t, err)
assert.Equal(t, int64(3), count)
}
func TestAuditService_EventTypeConstants(t *testing.T) {
// Verify all event constants have expected values
assert.Equal(t, "auth.login", AuditEventLogin)
assert.Equal(t, "auth.login_failed", AuditEventLoginFailed)
assert.Equal(t, "auth.register", AuditEventRegister)
assert.Equal(t, "auth.logout", AuditEventLogout)
assert.Equal(t, "auth.password_reset", AuditEventPasswordReset)
assert.Equal(t, "auth.password_changed", AuditEventPasswordChanged)
assert.Equal(t, "auth.account_deleted", AuditEventAccountDeleted)
}
func TestAuditService_Stop_FlushesRemainingEntries(t *testing.T) {
db := testutil.SetupTestDB(t)
svc := NewAuditService(db)
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// Send many events quickly
for i := 0; i < 50; i++ {
uid := uint(i)
svc.LogEvent(c, &uid, AuditEventLogin, nil)
}
// Stop should block until all entries are written
svc.Stop()
var count int64
err := db.Model(&models.AuditLog{}).Count(&count).Error
require.NoError(t, err)
assert.Equal(t, int64(50), count)
}
func TestAuditLog_TableName(t *testing.T) {
log := models.AuditLog{}
assert.Equal(t, "audit_log", log.TableName())
}
func TestAuditLog_JSONMap_NilHandling(t *testing.T) {
db := testutil.SetupTestDB(t)
// Create entry with nil details
entry := &models.AuditLog{
EventType: "test",
CreatedAt: time.Now().UTC(),
}
err := db.Create(entry).Error
require.NoError(t, err)
// Read it back
var found models.AuditLog
err = db.First(&found, entry.ID).Error
require.NoError(t, err)
assert.Nil(t, found.Details)
}

View File

@@ -0,0 +1,173 @@
package services
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/repositories"
)
func setupRefreshTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err)
err = db.AutoMigrate(&models.User{}, &models.UserProfile{}, &models.AuthToken{})
require.NoError(t, err)
return db
}
func createRefreshTestUser(t *testing.T, db *gorm.DB) *models.User {
t.Helper()
user := &models.User{
Username: "refreshtest",
Email: "refresh@test.com",
IsActive: true,
}
require.NoError(t, user.SetPassword("password123"))
require.NoError(t, db.Create(user).Error)
return user
}
func createTokenWithAge(t *testing.T, db *gorm.DB, userID uint, ageDays int) *models.AuthToken {
t.Helper()
token := &models.AuthToken{
UserID: userID,
}
require.NoError(t, db.Create(token).Error)
// Backdate the token's Created timestamp after creation to bypass autoCreateTime
backdated := time.Now().UTC().AddDate(0, 0, -ageDays)
require.NoError(t, db.Model(token).Update("created", backdated).Error)
token.Created = backdated
return token
}
func newTestAuthService(db *gorm.DB) *AuthService {
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{
Security: config.SecurityConfig{
SecretKey: "test-secret",
TokenExpiryDays: 90,
TokenRefreshDays: 60,
},
}
return NewAuthService(userRepo, cfg)
}
func TestRefreshToken_FreshToken_ReturnsExisting(t *testing.T) {
db := setupRefreshTestDB(t)
user := createRefreshTestUser(t, db)
token := createTokenWithAge(t, db, user.ID, 30) // 30 days old, well within fresh window
svc := newTestAuthService(db)
resp, err := svc.RefreshToken(token.Key, user.ID)
require.NoError(t, err)
assert.Equal(t, token.Key, resp.Token, "fresh token should return the same token")
assert.Contains(t, resp.Message, "still valid")
}
func TestRefreshToken_InRenewalWindow_ReturnsNewToken(t *testing.T) {
db := setupRefreshTestDB(t)
user := createRefreshTestUser(t, db)
token := createTokenWithAge(t, db, user.ID, 75) // 75 days old, in renewal window (60-90)
svc := newTestAuthService(db)
resp, err := svc.RefreshToken(token.Key, user.ID)
require.NoError(t, err)
assert.NotEqual(t, token.Key, resp.Token, "should return a new token")
assert.Contains(t, resp.Message, "refreshed")
// Verify old token was deleted
var count int64
db.Model(&models.AuthToken{}).Where("key = ?", token.Key).Count(&count)
assert.Equal(t, int64(0), count, "old token should be deleted")
// Verify new token exists in DB
db.Model(&models.AuthToken{}).Where("key = ?", resp.Token).Count(&count)
assert.Equal(t, int64(1), count, "new token should exist in DB")
// Verify new token belongs to the same user
var newToken models.AuthToken
require.NoError(t, db.Where("key = ?", resp.Token).First(&newToken).Error)
assert.Equal(t, user.ID, newToken.UserID)
}
func TestRefreshToken_ExpiredToken_Returns401(t *testing.T) {
db := setupRefreshTestDB(t)
user := createRefreshTestUser(t, db)
token := createTokenWithAge(t, db, user.ID, 91) // 91 days old, past 90-day expiry
svc := newTestAuthService(db)
resp, err := svc.RefreshToken(token.Key, user.ID)
require.Error(t, err)
assert.Nil(t, resp)
assert.Contains(t, err.Error(), "error.token_expired")
}
func TestRefreshToken_AtExactBoundary60Days(t *testing.T) {
db := setupRefreshTestDB(t)
user := createRefreshTestUser(t, db)
// Exactly 60 days: token age == refreshDays, so tokenAge < refreshDuration is false,
// meaning it enters the renewal window
token := createTokenWithAge(t, db, user.ID, 61)
svc := newTestAuthService(db)
resp, err := svc.RefreshToken(token.Key, user.ID)
require.NoError(t, err)
assert.NotEqual(t, token.Key, resp.Token, "token at 61 days should be refreshed")
}
func TestRefreshToken_InvalidToken_Returns401(t *testing.T) {
db := setupRefreshTestDB(t)
user := createRefreshTestUser(t, db)
svc := newTestAuthService(db)
resp, err := svc.RefreshToken("nonexistent-token-key", user.ID)
require.Error(t, err)
assert.Nil(t, resp)
assert.Contains(t, err.Error(), "error.invalid_token")
}
func TestRefreshToken_WrongUser_Returns401(t *testing.T) {
db := setupRefreshTestDB(t)
user := createRefreshTestUser(t, db)
token := createTokenWithAge(t, db, user.ID, 75)
svc := newTestAuthService(db)
// Try to refresh with a different user ID
resp, err := svc.RefreshToken(token.Key, user.ID+999)
require.Error(t, err)
assert.Nil(t, resp)
assert.Contains(t, err.Error(), "error.invalid_token")
}
func TestRefreshToken_FreshTokenAt59Days_ReturnsExisting(t *testing.T) {
db := setupRefreshTestDB(t)
user := createRefreshTestUser(t, db)
token := createTokenWithAge(t, db, user.ID, 59) // 59 days, just under the 60-day threshold
svc := newTestAuthService(db)
resp, err := svc.RefreshToken(token.Key, user.ID)
require.NoError(t, err)
assert.Equal(t, token.Key, resp.Token, "token at 59 days should NOT be refreshed")
}

View File

@@ -188,6 +188,66 @@ func (s *AuthService) Register(req *requests.RegisterRequest) (*responses.Regist
}, code, nil
}
// RefreshToken handles token refresh logic.
// - If token is expired (> expiryDays old), returns error (must re-login).
// - If token is in the renewal window (> refreshDays old), generates a new token.
// - If token is still fresh (< refreshDays old), returns the existing token (no-op).
func (s *AuthService) RefreshToken(tokenKey string, userID uint) (*responses.RefreshTokenResponse, error) {
expiryDays := s.cfg.Security.TokenExpiryDays
if expiryDays <= 0 {
expiryDays = 90
}
refreshDays := s.cfg.Security.TokenRefreshDays
if refreshDays <= 0 {
refreshDays = 60
}
// Look up the token
authToken, err := s.userRepo.FindTokenByKey(tokenKey)
if err != nil {
return nil, apperrors.Unauthorized("error.invalid_token")
}
// Verify ownership
if authToken.UserID != userID {
return nil, apperrors.Unauthorized("error.invalid_token")
}
tokenAge := time.Since(authToken.Created)
expiryDuration := time.Duration(expiryDays) * 24 * time.Hour
refreshDuration := time.Duration(refreshDays) * 24 * time.Hour
// Token is expired — must re-login
if tokenAge > expiryDuration {
return nil, apperrors.Unauthorized("error.token_expired")
}
// Token is still fresh — no-op refresh
if tokenAge < refreshDuration {
return &responses.RefreshTokenResponse{
Token: tokenKey,
Message: "Token is still valid.",
}, nil
}
// Token is in the renewal window — generate a new one
// Delete the old token
if err := s.userRepo.DeleteToken(tokenKey); err != nil {
log.Warn().Err(err).Str("token", tokenKey[:8]+"...").Msg("Failed to delete old token during refresh")
}
// Create a new token
newToken, err := s.userRepo.CreateToken(userID)
if err != nil {
return nil, apperrors.Internal(err)
}
return &responses.RefreshTokenResponse{
Token: newToken.Key,
Message: "Token refreshed successfully.",
}, nil
}
// Logout invalidates a user's token
func (s *AuthService) Logout(token string) error {
return s.userRepo.DeleteToken(token)

View File

@@ -141,6 +141,12 @@ func (c *CacheService) CacheAuthToken(ctx context.Context, token string, userID
return c.SetString(ctx, key, fmt.Sprintf("%d", userID), TokenCacheTTL)
}
// CacheAuthTokenWithCreated caches a user ID and token creation time for a token
func (c *CacheService) CacheAuthTokenWithCreated(ctx context.Context, token string, userID uint, createdUnix int64) error {
key := AuthTokenPrefix + token
return c.SetString(ctx, key, fmt.Sprintf("%d|%d", userID, createdUnix), TokenCacheTTL)
}
// GetCachedAuthToken gets a cached user ID for a token
func (c *CacheService) GetCachedAuthToken(ctx context.Context, token string) (uint, error) {
key := AuthTokenPrefix + token
@@ -154,6 +160,24 @@ func (c *CacheService) GetCachedAuthToken(ctx context.Context, token string) (ui
return userID, err
}
// GetCachedAuthTokenWithCreated gets a cached user ID and token creation time.
// Returns userID, createdUnix, error. createdUnix is 0 if not stored (legacy format).
func (c *CacheService) GetCachedAuthTokenWithCreated(ctx context.Context, token string) (uint, int64, error) {
key := AuthTokenPrefix + token
val, err := c.GetString(ctx, key)
if err != nil {
return 0, 0, err
}
var userID uint
var createdUnix int64
n, _ := fmt.Sscanf(val, "%d|%d", &userID, &createdUnix)
if n < 1 {
return 0, 0, fmt.Errorf("invalid cached token format")
}
return userID, createdUnix, nil
}
// InvalidateAuthToken removes a cached token
func (c *CacheService) InvalidateAuthToken(ctx context.Context, token string) error {
key := AuthTokenPrefix + token

View File

@@ -28,6 +28,7 @@ func NewEmailService(cfg *config.EmailConfig, enabled bool) *EmailService {
mail.WithUsername(cfg.User),
mail.WithPassword(cfg.Password),
mail.WithTLSPortPolicy(mail.TLSOpportunistic),
mail.WithTimeout(30*time.Second),
)
if err != nil {
log.Error().Err(err).Msg("Failed to create mail client - emails will not be sent")