81e454d86d
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>
197 lines
6.8 KiB
Go
197 lines
6.8 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"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/kratos"
|
|
"github.com/treytartt/honeydue-api/internal/repositories"
|
|
)
|
|
|
|
// AuthService handles user profile and account management. Session
|
|
// authentication is now delegated to Ory Kratos via KratosAuth middleware.
|
|
type AuthService struct {
|
|
userRepo *repositories.UserRepository
|
|
notificationRepo *repositories.NotificationRepository
|
|
cache *CacheService
|
|
kratos *kratos.Client
|
|
}
|
|
|
|
// NewAuthService creates a new auth service.
|
|
func NewAuthService(userRepo *repositories.UserRepository, _ interface{}) *AuthService {
|
|
return &AuthService{
|
|
userRepo: userRepo,
|
|
}
|
|
}
|
|
|
|
// SetNotificationRepository wires the notification repo (kept for startup
|
|
// compatibility — no longer used post-Kratos).
|
|
func (s *AuthService) SetNotificationRepository(notificationRepo *repositories.NotificationRepository) {
|
|
s.notificationRepo = notificationRepo
|
|
}
|
|
|
|
// SetCacheService wires Redis (kept for startup compatibility).
|
|
func (s *AuthService) SetCacheService(cache *CacheService) {
|
|
s.cache = cache
|
|
}
|
|
|
|
// SetKratosClient wires the Ory Kratos client so account deletion also
|
|
// removes the user's Kratos identity. When nil (e.g. in tests) the Kratos
|
|
// teardown is skipped.
|
|
func (s *AuthService) SetKratosClient(k *kratos.Client) {
|
|
s.kratos = k
|
|
}
|
|
|
|
// Register admin-creates a Kratos identity for a new password account with an
|
|
// unverified email and no auto-sent verification email (see
|
|
// kratos.Client.CreateIdentity for the rationale). The client logs in
|
|
// immediately afterward to obtain a session, then drives email verification
|
|
// explicitly. No local auth_user row is created here — the KratosAuth
|
|
// middleware lazily provisions it on the first authenticated request.
|
|
func (s *AuthService) Register(ctx context.Context, req *requests.RegisterRequest) error {
|
|
if s.kratos == nil {
|
|
return apperrors.Internal(errors.New("identity service unavailable"))
|
|
}
|
|
_, err := s.kratos.CreateIdentity(ctx, req.Email, req.FirstName, req.LastName, req.Password)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
switch {
|
|
case errors.Is(err, kratos.ErrIdentityExists):
|
|
return apperrors.Conflict("error.email_already_taken")
|
|
default:
|
|
var invalid *kratos.ErrInvalidCredentials
|
|
if errors.As(err, &invalid) {
|
|
return apperrors.BadRequest("error.password_complexity")
|
|
}
|
|
log.Error().Err(err).Msg("Kratos identity creation failed")
|
|
return apperrors.Internal(err)
|
|
}
|
|
}
|
|
|
|
// GetCurrentUser returns the current authenticated user with profile.
|
|
func (s *AuthService) GetCurrentUser(ctx context.Context, userID uint) (*responses.CurrentUserResponse, error) {
|
|
user, err := s.userRepo.WithContext(ctx).FindByIDWithProfile(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
authProvider, err := s.userRepo.WithContext(ctx).FindAuthProvider(userID)
|
|
if err != nil {
|
|
log.Warn().Err(err).Uint("user_id", userID).Msg("Failed to determine auth provider")
|
|
authProvider = "kratos"
|
|
}
|
|
|
|
response := responses.NewCurrentUserResponse(user, authProvider)
|
|
return &response, nil
|
|
}
|
|
|
|
// MarkUserVerified flips the local user_profile.verified and auth_user.verified
|
|
// mirrors to true. Called opportunistically from CurrentUser when the live
|
|
// Kratos email-verified flag is true but the local mirror is stale (the user
|
|
// completed verification after the local row was provisioned). Best-effort:
|
|
// the canonical truth lives in Kratos's verifiable_addresses, so a failure
|
|
// here only means a brief stale read on background-only queries.
|
|
func (s *AuthService) MarkUserVerified(ctx context.Context, userID uint) error {
|
|
if err := s.userRepo.WithContext(ctx).MarkVerified(userID); err != nil {
|
|
log.Warn().Err(err).Uint("user_id", userID).Msg("Failed to mirror verified flag from Kratos")
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateProfile updates a user's profile fields.
|
|
func (s *AuthService) UpdateProfile(ctx context.Context, userID uint, req *requests.UpdateProfileRequest) (*responses.CurrentUserResponse, error) {
|
|
user, err := s.userRepo.WithContext(ctx).FindByID(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if req.Email != nil && *req.Email != user.Email {
|
|
exists, err := s.userRepo.WithContext(ctx).ExistsByEmail(*req.Email)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if exists {
|
|
return nil, apperrors.Conflict("error.email_already_taken")
|
|
}
|
|
user.Email = *req.Email
|
|
}
|
|
|
|
if req.FirstName != nil {
|
|
user.FirstName = *req.FirstName
|
|
}
|
|
if req.LastName != nil {
|
|
user.LastName = *req.LastName
|
|
}
|
|
|
|
if err := s.userRepo.WithContext(ctx).Update(user); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
user, err = s.userRepo.WithContext(ctx).FindByIDWithProfile(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
authProvider, err := s.userRepo.WithContext(ctx).FindAuthProvider(userID)
|
|
if err != nil {
|
|
log.Warn().Err(err).Uint("user_id", userID).Msg("Failed to determine auth provider")
|
|
authProvider = "kratos"
|
|
}
|
|
|
|
response := responses.NewCurrentUserResponse(user, authProvider)
|
|
return &response, nil
|
|
}
|
|
|
|
// DeleteAccount deletes a user's account and all associated data.
|
|
// Kratos owns credentials; confirmation string "DELETE" is required from the
|
|
// caller since we can no longer verify a password here.
|
|
//
|
|
// The Kratos identity is deleted FIRST: Kratos owns the credentials, so if
|
|
// that call fails the local data is left intact and an error is returned for
|
|
// the caller to retry. Deleting local data first would risk an orphaned
|
|
// Kratos identity that can still authenticate against a half-deleted account.
|
|
func (s *AuthService) DeleteAccount(ctx context.Context, userID uint, _ *string, confirmation *string) ([]string, error) {
|
|
if confirmation == nil || *confirmation != "DELETE" {
|
|
return nil, apperrors.BadRequest("error.confirmation_required")
|
|
}
|
|
|
|
user, err := s.userRepo.WithContext(ctx).FindByID(userID)
|
|
if err != nil {
|
|
if errors.Is(err, repositories.ErrUserNotFound) {
|
|
return nil, apperrors.NotFound("error.user_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Tear down the Kratos identity before touching local data. DeleteIdentity
|
|
// treats a 404 as success, so a retry after a partial failure is safe.
|
|
if s.kratos != nil && user.KratosID != "" {
|
|
if err := s.kratos.DeleteIdentity(ctx, user.KratosID); err != nil {
|
|
return nil, apperrors.Internal(fmt.Errorf("delete kratos identity: %w", err))
|
|
}
|
|
}
|
|
|
|
var fileURLs []string
|
|
txErr := s.userRepo.WithContext(ctx).Transaction(func(txRepo *repositories.UserRepository) error {
|
|
urls, err := txRepo.DeleteUserCascade(userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fileURLs = urls
|
|
return nil
|
|
})
|
|
if txErr != nil {
|
|
return nil, apperrors.Internal(txErr)
|
|
}
|
|
|
|
return fileURLs, nil
|
|
}
|