Files
honeyDueAPI/internal/handlers/auth_handler.go
T
Trey t 81e454d86d
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Add admin-create registration + live email-verified flag
Registration now goes through POST /api/auth/register, which admin-creates the
Kratos identity (unverified email, NO auto-sent code). Kratos self-service
registration never returns the verification flow id, so the client could never
submit the user's code to the right flow; admin creation lets the client own a
single verification flow instead. Also surface the live Kratos verified flag
and fix Apple audience + team IDs.

- kratos.Client.CreateIdentity via admin API; ErrIdentityExists / ErrInvalidCredentials
- AuthService.Register + AuthHandler.Register + public POST /api/auth/register/
- CurrentUser overrides stale user_profile.verified with the live Kratos flag;
  UserRepository.MarkVerified mirrors it back
- configmap: additional_id_token_audiences allows the .dev bundle id_token
- fix Apple/APNs team id V3PF3M6B6U -> X86BR9WTLD in .env.example + dev init

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:46:30 -05:00

178 lines
5.9 KiB
Go

package handlers
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/dto/requests"
"github.com/treytartt/honeydue-api/internal/dto/responses"
"github.com/treytartt/honeydue-api/internal/middleware"
"github.com/treytartt/honeydue-api/internal/services"
"github.com/treytartt/honeydue-api/internal/validator"
)
// AuthHandler handles user profile and account management endpoints.
// Session lifecycle (login, register, logout, password reset) is delegated
// to Ory Kratos; this handler only deals with the honeyDue user record.
type AuthHandler struct {
authService *services.AuthService
emailService *services.EmailService
cache *services.CacheService
storageService *services.StorageService
auditService *services.AuditService
}
// NewAuthHandler creates a new auth handler.
func NewAuthHandler(authService *services.AuthService, emailService *services.EmailService, cache *services.CacheService) *AuthHandler {
return &AuthHandler{
authService: authService,
emailService: emailService,
cache: cache,
}
}
// SetStorageService sets the storage service for file deletion during account deletion.
func (h *AuthHandler) SetStorageService(storageService *services.StorageService) {
h.storageService = storageService
}
// SetAuditService sets the audit service for logging security events.
func (h *AuthHandler) SetAuditService(auditService *services.AuditService) {
h.auditService = auditService
}
// noStore marks a response as non-cacheable.
func noStore(c echo.Context) {
c.Response().Header().Set("Cache-Control", "no-store")
}
// Register handles POST /api/auth/register/ — creates a new password account.
//
// The identity is admin-created in Kratos with an unverified email and no
// auto-sent code (see services.AuthService.Register). The client logs in right
// after to get a session, then completes email verification. Returns 201 with
// no token; 409 if the email is taken; 400 on a weak password.
func (h *AuthHandler) Register(c echo.Context) error {
var req requests.RegisterRequest
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request_body")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
if err := h.authService.Register(c.Request().Context(), &req); err != nil {
return err
}
return c.JSON(http.StatusCreated, map[string]string{
"message": "Account created. Please verify your email.",
})
}
// CurrentUser handles GET /api/auth/me/
func (h *AuthHandler) CurrentUser(c echo.Context) error {
noStore(c)
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
response, err := h.authService.GetCurrentUser(c.Request().Context(), user.ID)
if err != nil {
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to get current user")
return err
}
// user_profile.verified is a one-time mirror set at provision time
// (see middleware/kratos_auth.go::provision). Kratos remains the source
// of truth for email-verification state — it can flip from false → true
// the instant the user completes the verification flow, and nothing
// updates the local column. Override the response with the live value
// the Kratos auth middleware already stashed in context so /auth/me
// reflects current reality. Also opportunistically sync the DB mirror
// (best-effort, ignore error) so background queries that read the
// column see the same answer.
if verified, ok := c.Get(middleware.AuthVerifiedKey).(bool); ok {
mirrorStale := response.Profile != nil && response.Profile.Verified != verified
if response.Profile != nil {
response.Profile.Verified = verified
}
if verified && mirrorStale {
_ = h.authService.MarkUserVerified(c.Request().Context(), user.ID)
}
}
return c.JSON(http.StatusOK, response)
}
// UpdateProfile handles PUT/PATCH /api/auth/profile/
func (h *AuthHandler) UpdateProfile(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
var req requests.UpdateProfileRequest
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
response, err := h.authService.UpdateProfile(c.Request().Context(), user.ID, &req)
if err != nil {
log.Debug().Err(err).Uint("user_id", user.ID).Msg("Failed to update profile")
return err
}
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(c.Request().Context(), user.ID, req.Password, req.Confirmation)
if err != nil {
log.Debug().Err(err).Uint("user_id", user.ID).Msg("Account deletion failed")
return err
}
if h.auditService != nil {
h.auditService.LogEvent(c, &user.ID, services.AuditEventAccountDeleted, map[string]interface{}{
"user_id": user.ID,
"username": user.Username,
"email": user.Email,
})
}
// 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")
}
}
}()
}
return c.JSON(http.StatusOK, responses.MessageResponse{Message: "Account deleted successfully"})
}