Add delete account endpoint and file encryption at rest
Delete Account (Plan #2): - DELETE /api/auth/account/ with password or "DELETE" confirmation - Cascade delete across 15+ tables in correct FK order - Auth provider detection (email/apple/google) for /auth/me/ - File cleanup after account deletion - Handler + repository tests (12 tests) Encryption at Rest (Plan #3): - AES-256-GCM envelope encryption (per-file DEK wrapped by KEK) - Encrypt on upload, auto-decrypt on serve via StorageService.ReadFile() - MediaHandler serves decrypted files via c.Blob() - TaskService email image loading uses ReadFile() - cmd/migrate-encrypt CLI tool with --dry-run for existing files - Encryption service + storage service tests (18 tests)
This commit is contained in:
@@ -22,6 +22,7 @@ type AuthHandler struct {
|
||||
cache *services.CacheService
|
||||
appleAuthService *services.AppleAuthService
|
||||
googleAuthService *services.GoogleAuthService
|
||||
storageService *services.StorageService
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new auth handler
|
||||
@@ -43,6 +44,11 @@ func (h *AuthHandler) SetGoogleAuthService(googleAuth *services.GoogleAuthServic
|
||||
h.googleAuthService = googleAuth
|
||||
}
|
||||
|
||||
// SetStorageService sets the storage service for file deletion during account deletion
|
||||
func (h *AuthHandler) SetStorageService(storageService *services.StorageService) {
|
||||
h.storageService = storageService
|
||||
}
|
||||
|
||||
// Login handles POST /api/auth/login/
|
||||
func (h *AuthHandler) Login(c echo.Context) error {
|
||||
var req requests.LoginRequest
|
||||
@@ -406,3 +412,48 @@ func (h *AuthHandler) GoogleSignIn(c echo.Context) error {
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// DeleteAccount handles DELETE /api/auth/account/
|
||||
func (h *AuthHandler) DeleteAccount(c echo.Context) error {
|
||||
user, err := middleware.MustGetAuthUser(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var req requests.DeleteAccountRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return apperrors.BadRequest("error.invalid_request")
|
||||
}
|
||||
|
||||
fileURLs, err := h.authService.DeleteAccount(user.ID, req.Password, req.Confirmation)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Uint("user_id", user.ID).Msg("Account deletion failed")
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete files from disk (best effort, don't fail the request)
|
||||
if h.storageService != nil && len(fileURLs) > 0 {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error().Interface("panic", r).Uint("user_id", user.ID).Msg("Panic in file cleanup goroutine")
|
||||
}
|
||||
}()
|
||||
for _, fileURL := range fileURLs {
|
||||
if err := h.storageService.Delete(fileURL); err != nil {
|
||||
log.Warn().Err(err).Str("file_url", fileURL).Msg("Failed to delete file during account cleanup")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Invalidate auth token from cache
|
||||
token := middleware.GetAuthToken(c)
|
||||
if h.cache != nil && token != "" {
|
||||
if err := h.cache.InvalidateAuthToken(c.Request().Context(), token); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to invalidate token in cache after account deletion")
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, responses.MessageResponse{Message: "Account deleted successfully"})
|
||||
}
|
||||
|
||||
217
internal/handlers/auth_handler_delete_test.go
Normal file
217
internal/handlers/auth_handler_delete_test.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/config"
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
"github.com/treytartt/honeydue-api/internal/repositories"
|
||||
"github.com/treytartt/honeydue-api/internal/services"
|
||||
"github.com/treytartt/honeydue-api/internal/testutil"
|
||||
)
|
||||
|
||||
func setupDeleteAccountHandler(t *testing.T) (*AuthHandler, *echo.Echo, *gorm.DB) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
userRepo := repositories.NewUserRepository(db)
|
||||
cfg := &config.Config{
|
||||
Security: config.SecurityConfig{
|
||||
SecretKey: "test-secret-key",
|
||||
PasswordResetExpiry: 15 * time.Minute,
|
||||
ConfirmationExpiry: 24 * time.Hour,
|
||||
MaxPasswordResetRate: 3,
|
||||
},
|
||||
}
|
||||
authService := services.NewAuthService(userRepo, cfg)
|
||||
handler := NewAuthHandler(authService, nil, nil)
|
||||
e := testutil.SetupTestRouter()
|
||||
return handler, e, db
|
||||
}
|
||||
|
||||
func TestAuthHandler_DeleteAccount_EmailUser(t *testing.T) {
|
||||
handler, e, db := setupDeleteAccountHandler(t)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "deletetest", "delete@test.com", "password123")
|
||||
|
||||
// Create profile for the user
|
||||
profile := &models.UserProfile{UserID: user.ID, Verified: true}
|
||||
require.NoError(t, db.Create(profile).Error)
|
||||
|
||||
// Create auth token
|
||||
testutil.CreateTestToken(t, db, user.ID)
|
||||
|
||||
authGroup := e.Group("/api/auth")
|
||||
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||
authGroup.DELETE("/account/", handler.DeleteAccount)
|
||||
|
||||
t.Run("successful deletion with correct password", func(t *testing.T) {
|
||||
password := "password123"
|
||||
req := map[string]interface{}{
|
||||
"password": password,
|
||||
}
|
||||
|
||||
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token")
|
||||
|
||||
testutil.AssertStatusCode(t, w, http.StatusOK)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, response["message"], "Account deleted successfully")
|
||||
|
||||
// Verify user is actually deleted
|
||||
var count int64
|
||||
db.Model(&models.User{}).Where("id = ?", user.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
|
||||
// Verify profile is deleted
|
||||
db.Model(&models.UserProfile{}).Where("user_id = ?", user.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
|
||||
// Verify auth token is deleted
|
||||
db.Model(&models.AuthToken{}).Where("user_id = ?", user.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthHandler_DeleteAccount_WrongPassword(t *testing.T) {
|
||||
handler, e, db := setupDeleteAccountHandler(t)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "wrongpw", "wrongpw@test.com", "password123")
|
||||
|
||||
authGroup := e.Group("/api/auth")
|
||||
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||
authGroup.DELETE("/account/", handler.DeleteAccount)
|
||||
|
||||
t.Run("wrong password returns 401", func(t *testing.T) {
|
||||
wrongPw := "wrongpassword"
|
||||
req := map[string]interface{}{
|
||||
"password": wrongPw,
|
||||
}
|
||||
|
||||
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token")
|
||||
|
||||
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthHandler_DeleteAccount_MissingPassword(t *testing.T) {
|
||||
handler, e, db := setupDeleteAccountHandler(t)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "nopw", "nopw@test.com", "password123")
|
||||
|
||||
authGroup := e.Group("/api/auth")
|
||||
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||
authGroup.DELETE("/account/", handler.DeleteAccount)
|
||||
|
||||
t.Run("missing password returns 400", func(t *testing.T) {
|
||||
req := map[string]interface{}{}
|
||||
|
||||
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token")
|
||||
|
||||
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthHandler_DeleteAccount_SocialAuthUser(t *testing.T) {
|
||||
handler, e, db := setupDeleteAccountHandler(t)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "appleuser", "apple@test.com", "randompassword")
|
||||
|
||||
// Create Apple social auth record
|
||||
appleAuth := &models.AppleSocialAuth{
|
||||
UserID: user.ID,
|
||||
AppleID: "apple_sub_123",
|
||||
Email: "apple@test.com",
|
||||
}
|
||||
require.NoError(t, db.Create(appleAuth).Error)
|
||||
|
||||
// Create profile
|
||||
profile := &models.UserProfile{UserID: user.ID, Verified: true}
|
||||
require.NoError(t, db.Create(profile).Error)
|
||||
|
||||
authGroup := e.Group("/api/auth")
|
||||
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||
authGroup.DELETE("/account/", handler.DeleteAccount)
|
||||
|
||||
t.Run("successful deletion with DELETE confirmation", func(t *testing.T) {
|
||||
confirmation := "DELETE"
|
||||
req := map[string]interface{}{
|
||||
"confirmation": confirmation,
|
||||
}
|
||||
|
||||
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token")
|
||||
|
||||
testutil.AssertStatusCode(t, w, http.StatusOK)
|
||||
|
||||
// Verify user is deleted
|
||||
var count int64
|
||||
db.Model(&models.User{}).Where("id = ?", user.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
|
||||
// Verify apple auth is deleted
|
||||
db.Model(&models.AppleSocialAuth{}).Where("user_id = ?", user.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthHandler_DeleteAccount_SocialAuthMissingConfirmation(t *testing.T) {
|
||||
handler, e, db := setupDeleteAccountHandler(t)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "googleuser", "google@test.com", "randompassword")
|
||||
|
||||
// Create Google social auth record
|
||||
googleAuth := &models.GoogleSocialAuth{
|
||||
UserID: user.ID,
|
||||
GoogleID: "google_sub_456",
|
||||
Email: "google@test.com",
|
||||
}
|
||||
require.NoError(t, db.Create(googleAuth).Error)
|
||||
|
||||
authGroup := e.Group("/api/auth")
|
||||
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||
authGroup.DELETE("/account/", handler.DeleteAccount)
|
||||
|
||||
t.Run("missing confirmation returns 400", func(t *testing.T) {
|
||||
req := map[string]interface{}{}
|
||||
|
||||
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token")
|
||||
|
||||
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
t.Run("wrong confirmation returns 400", func(t *testing.T) {
|
||||
wrongConfirmation := "delete"
|
||||
req := map[string]interface{}{
|
||||
"confirmation": wrongConfirmation,
|
||||
}
|
||||
|
||||
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token")
|
||||
|
||||
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthHandler_DeleteAccount_Unauthenticated(t *testing.T) {
|
||||
handler, e, _ := setupDeleteAccountHandler(t)
|
||||
|
||||
// No auth middleware - unauthenticated request
|
||||
e.DELETE("/api/auth/account/", handler.DeleteAccount)
|
||||
|
||||
t.Run("unauthenticated request returns 401", func(t *testing.T) {
|
||||
req := map[string]interface{}{
|
||||
"password": "password123",
|
||||
}
|
||||
|
||||
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "")
|
||||
|
||||
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -60,15 +62,18 @@ func (h *MediaHandler) ServeDocument(c echo.Context) error {
|
||||
return apperrors.Forbidden("error.access_denied")
|
||||
}
|
||||
|
||||
// Serve the file
|
||||
filePath := h.resolveFilePath(doc.FileURL)
|
||||
if filePath == "" {
|
||||
// Serve the file (supports encrypted files transparently)
|
||||
data, mimeType, err := h.storageSvc.ReadFile(doc.FileURL)
|
||||
if err != nil {
|
||||
return apperrors.NotFound("error.file_not_found")
|
||||
}
|
||||
|
||||
// Set caching headers (private, 1 hour)
|
||||
// Set caching and disposition headers
|
||||
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
|
||||
return c.File(filePath)
|
||||
if doc.FileName != "" {
|
||||
c.Response().Header().Set("Content-Disposition", "inline; filename=\""+doc.FileName+"\"")
|
||||
}
|
||||
return c.Blob(http.StatusOK, mimeType, data)
|
||||
}
|
||||
|
||||
// ServeDocumentImage serves a document image with access control
|
||||
@@ -102,14 +107,15 @@ func (h *MediaHandler) ServeDocumentImage(c echo.Context) error {
|
||||
return apperrors.Forbidden("error.access_denied")
|
||||
}
|
||||
|
||||
// Serve the file
|
||||
filePath := h.resolveFilePath(img.ImageURL)
|
||||
if filePath == "" {
|
||||
// Serve the file (supports encrypted files transparently)
|
||||
data, mimeType, err := h.storageSvc.ReadFile(img.ImageURL)
|
||||
if err != nil {
|
||||
return apperrors.NotFound("error.file_not_found")
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
|
||||
return c.File(filePath)
|
||||
c.Response().Header().Set("Content-Disposition", "inline; filename=\""+filepath.Base(img.ImageURL)+"\"")
|
||||
return c.Blob(http.StatusOK, mimeType, data)
|
||||
}
|
||||
|
||||
// ServeCompletionImage serves a task completion image with access control
|
||||
@@ -149,14 +155,15 @@ func (h *MediaHandler) ServeCompletionImage(c echo.Context) error {
|
||||
return apperrors.Forbidden("error.access_denied")
|
||||
}
|
||||
|
||||
// Serve the file
|
||||
filePath := h.resolveFilePath(img.ImageURL)
|
||||
if filePath == "" {
|
||||
// Serve the file (supports encrypted files transparently)
|
||||
data, mimeType, err := h.storageSvc.ReadFile(img.ImageURL)
|
||||
if err != nil {
|
||||
return apperrors.NotFound("error.file_not_found")
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
|
||||
return c.File(filePath)
|
||||
c.Response().Header().Set("Content-Disposition", "inline; filename=\""+filepath.Base(img.ImageURL)+"\"")
|
||||
return c.Blob(http.StatusOK, mimeType, data)
|
||||
}
|
||||
|
||||
// resolveFilePath converts a stored URL to an actual file path.
|
||||
|
||||
Reference in New Issue
Block a user