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:
93
internal/services/audit_service.go
Normal file
93
internal/services/audit_service.go
Normal 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")
|
||||
}
|
||||
}
|
||||
176
internal/services/audit_service_test.go
Normal file
176
internal/services/audit_service_test.go
Normal 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)
|
||||
}
|
||||
173
internal/services/auth_refresh_test.go
Normal file
173
internal/services/auth_refresh_test.go
Normal 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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user