// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/auth/register.go package auth import ( "context" "crypto/rand" "encoding/base64" "encoding/hex" "fmt" "html" "net/mail" "strings" "time" "github.com/awnumar/memguard" "github.com/gocql/gocql" "go.uber.org/zap" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/crypto" dom_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/user" uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/auditlog" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/emailer/mailgun" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/transaction" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation" ) type RegisterRequestDTO struct { BetaAccessCode string `json:"beta_access_code"` Email string `json:"email"` FirstName string `json:"first_name"` LastName string `json:"last_name"` Phone string `json:"phone"` Country string `json:"country"` Timezone string `json:"timezone"` PasswordSalt string `json:"salt"` KDFAlgorithm string `json:"kdf_algorithm"` KDFIterations int `json:"kdf_iterations"` KDFMemory int `json:"kdf_memory"` KDFParallelism int `json:"kdf_parallelism"` KDFSaltLength int `json:"kdf_salt_length"` KDFKeyLength int `json:"kdf_key_length"` EncryptedMasterKey string `json:"encryptedMasterKey"` PublicKey string `json:"publicKey"` EncryptedPrivateKey string `json:"encryptedPrivateKey"` EncryptedRecoveryKey string `json:"encryptedRecoveryKey"` MasterKeyEncryptedWithRecoveryKey string `json:"masterKeyEncryptedWithRecoveryKey"` AgreeTermsOfService bool `json:"agree_terms_of_service"` AgreePromotions bool `json:"agree_promotions"` AgreeToTrackingAcrossThirdPartyAppsAndServices bool `json:"agree_to_tracking_across_third_party_apps_and_services"` } type RegisterResponseDTO struct { Message string `json:"message"` UserID string `json:"user_id"` } type RegisterService interface { Execute(ctx context.Context, req *RegisterRequestDTO) (*RegisterResponseDTO, error) } type registerServiceImpl struct { config *config.Config logger *zap.Logger auditLogger auditlog.AuditLogger userCreateUC uc_user.UserCreateUseCase userGetByEmailUC uc_user.UserGetByEmailUseCase userDeleteByIDUC uc_user.UserDeleteByIDUseCase emailer mailgun.Emailer } func NewRegisterService( config *config.Config, logger *zap.Logger, auditLogger auditlog.AuditLogger, userCreateUC uc_user.UserCreateUseCase, userGetByEmailUC uc_user.UserGetByEmailUseCase, userDeleteByIDUC uc_user.UserDeleteByIDUseCase, emailer mailgun.Emailer, ) RegisterService { return ®isterServiceImpl{ config: config, logger: logger.Named("RegisterService"), auditLogger: auditLogger, userCreateUC: userCreateUC, userGetByEmailUC: userGetByEmailUC, userDeleteByIDUC: userDeleteByIDUC, emailer: emailer, } } func (s *registerServiceImpl) Execute(ctx context.Context, req *RegisterRequestDTO) (*RegisterResponseDTO, error) { // Validate request first - backend is the single source of truth for validation if err := s.validateRegisterRequest(req); err != nil { return nil, err // Returns RFC 9457 ProblemDetail } // Create SAGA for user registration workflow saga := transaction.NewSaga("register", s.logger) s.logger.Info("starting user registration") // Step 1: Check if user already exists (read-only, no compensation) existingUser, err := s.userGetByEmailUC.Execute(ctx, req.Email) if err == nil && existingUser != nil { s.logger.Warn("User already exists", zap.String("email", validation.MaskEmail(req.Email))) return nil, httperror.NewConflictError("User with this email already exists") } // Step 2: Generate verification code verificationCode := s.generateVerificationCode() verificationExpiry := time.Now().Add(24 * time.Hour) // Step 3: Parse E2EE keys from base64 passwordSalt, err := s.decodeBase64(req.PasswordSalt) if err != nil { return nil, fmt.Errorf("invalid password salt: %w", err) } encryptedMasterKey, err := s.decodeBase64(req.EncryptedMasterKey) if err != nil { return nil, fmt.Errorf("invalid encrypted master key: %w", err) } publicKey, err := s.decodeBase64(req.PublicKey) if err != nil { return nil, fmt.Errorf("invalid public key: %w", err) } encryptedPrivateKey, err := s.decodeBase64(req.EncryptedPrivateKey) if err != nil { return nil, fmt.Errorf("invalid encrypted private key: %w", err) } encryptedRecoveryKey, err := s.decodeBase64(req.EncryptedRecoveryKey) if err != nil { return nil, fmt.Errorf("invalid encrypted recovery key: %w", err) } masterKeyEncryptedWithRecoveryKey, err := s.decodeBase64(req.MasterKeyEncryptedWithRecoveryKey) if err != nil { return nil, fmt.Errorf("invalid master key encrypted with recovery key: %w", err) } // Step 4: Create user object user := &dom_user.User{ ID: gocql.TimeUUID(), Email: req.Email, FirstName: req.FirstName, LastName: req.LastName, Name: req.FirstName + " " + req.LastName, LexicalName: req.LastName + ", " + req.FirstName, Role: dom_user.UserRoleIndividual, Status: dom_user.UserStatusActive, Timezone: req.Timezone, ProfileData: &dom_user.UserProfileData{ Phone: req.Phone, Country: req.Country, Timezone: req.Timezone, AgreeTermsOfService: req.AgreeTermsOfService, AgreePromotions: req.AgreePromotions, AgreeToTrackingAcrossThirdPartyAppsAndServices: req.AgreeToTrackingAcrossThirdPartyAppsAndServices, }, SecurityData: &dom_user.UserSecurityData{ WasEmailVerified: false, Code: verificationCode, CodeType: dom_user.UserCodeTypeEmailVerification, CodeExpiry: verificationExpiry, PasswordSalt: passwordSalt, KDFParams: crypto.KDFParams{ Algorithm: req.KDFAlgorithm, // Use the algorithm from the request (PBKDF2-SHA256 or argon2id) Iterations: uint32(req.KDFIterations), Memory: uint32(req.KDFMemory), Parallelism: uint8(req.KDFParallelism), SaltLength: uint32(req.KDFSaltLength), KeyLength: uint32(req.KDFKeyLength), }, EncryptedMasterKey: crypto.EncryptedMasterKey{ Nonce: encryptedMasterKey[:24], Ciphertext: encryptedMasterKey[24:], KeyVersion: 1, }, PublicKey: crypto.PublicKey{ Key: publicKey, }, EncryptedPrivateKey: crypto.EncryptedPrivateKey{ Nonce: encryptedPrivateKey[:24], Ciphertext: encryptedPrivateKey[24:], }, EncryptedRecoveryKey: crypto.EncryptedRecoveryKey{ Nonce: encryptedRecoveryKey[:24], Ciphertext: encryptedRecoveryKey[24:], }, MasterKeyEncryptedWithRecoveryKey: crypto.MasterKeyEncryptedWithRecoveryKey{ Nonce: masterKeyEncryptedWithRecoveryKey[:24], Ciphertext: masterKeyEncryptedWithRecoveryKey[24:], }, }, CreatedAt: time.Now(), ModifiedAt: time.Now(), } // Step 5: Save user to database FIRST (compensate: delete user if email fails) // CRITICAL: Create user before sending email to enable rollback if email fails if err := s.userCreateUC.Execute(ctx, user); err != nil { s.logger.Error("Failed to create user", zap.Error(err)) return nil, fmt.Errorf("failed to create user: %w", err) } // Register compensation: delete user if email sending fails userIDCaptured := user.ID saga.AddCompensation(func(ctx context.Context) error { s.logger.Info("compensating: deleting user due to email failure", zap.String("user_id", userIDCaptured.String()), zap.String("email", validation.MaskEmail(req.Email))) return s.userDeleteByIDUC.Execute(ctx, userIDCaptured) }) // Step 6: Send verification email - MUST succeed or rollback // NOTE: Default tags are NOT created server-side due to E2EE // The client must create default tags after first login using the user's master key if err := s.sendVerificationEmail(ctx, req.Email, req.FirstName, verificationCode); err != nil { s.logger.Error("Failed to send verification email", zap.String("email", validation.MaskEmail(req.Email)), zap.Error(err)) // Trigger compensation: Delete user from database saga.Rollback(ctx) return nil, fmt.Errorf("failed to send verification email, please try again later") } s.logger.Info("User registered successfully", zap.String("user_id", user.ID.String()), zap.String("email", validation.MaskEmail(req.Email))) // Audit log successful registration s.auditLogger.LogAuth(ctx, auditlog.EventTypeAccountCreated, auditlog.OutcomeSuccess, validation.MaskEmail(req.Email), "", map[string]string{ "user_id": user.ID.String(), }) return &RegisterResponseDTO{ Message: "Registration successful. Please check your email to verify your account.", UserID: user.ID.String(), }, nil } func (s *registerServiceImpl) generateVerificationCode() string { // Generate random 8-digit code for increased entropy // 8 digits = 90,000,000 combinations vs 6 digits = 900,000 b := make([]byte, 4) rand.Read(b) defer memguard.WipeBytes(b) // SECURITY: Wipe random bytes after use code := int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3]) code = (code % 90000000) + 10000000 return fmt.Sprintf("%d", code) } func (s *registerServiceImpl) decodeBase64(encoded string) ([]byte, error) { // Try base64 first decoded, err := base64.StdEncoding.DecodeString(encoded) if err == nil { return decoded, nil } // If base64 fails, try hex encoding (some clients send hex) if hexDecoded, hexErr := hex.DecodeString(encoded); hexErr == nil { return hexDecoded, nil } // Return original base64 error return nil, err } func (s *registerServiceImpl) sendVerificationEmail(ctx context.Context, email, firstName, code string) error { subject := "Verify Your MapleFile Account" sender := s.emailer.GetSenderEmail() // Escape user input to prevent HTML injection safeFirstName := html.EscapeString(firstName) htmlContent := fmt.Sprintf(`

Welcome to MapleFile, %s!

Thank you for registering. Please verify your email address by entering this code:

%s

This code will expire in 24 hours.

If you didn't create this account, please ignore this email.

`, safeFirstName, code) return s.emailer.Send(ctx, sender, subject, email, htmlContent) } // validateRegisterRequest validates all registration fields. // Returns RFC 9457 ProblemDetail error with field-specific errors. func (s *registerServiceImpl) validateRegisterRequest(req *RegisterRequestDTO) error { errors := make(map[string]string) // Validate beta access code if strings.TrimSpace(req.BetaAccessCode) == "" { errors["beta_access_code"] = "Beta access code is required" } // Validate first name if strings.TrimSpace(req.FirstName) == "" { errors["first_name"] = "First name is required" } else if len(req.FirstName) > 100 { errors["first_name"] = "First name must be less than 100 characters" } // Validate last name if strings.TrimSpace(req.LastName) == "" { errors["last_name"] = "Last name is required" } else if len(req.LastName) > 100 { errors["last_name"] = "Last name must be less than 100 characters" } // Validate email email := strings.TrimSpace(req.Email) if email == "" { errors["email"] = "Email is required" } else { // Use Go's mail package for proper email validation if _, err := mail.ParseAddress(email); err != nil { errors["email"] = "Please enter a valid email address" } } // Validate phone if strings.TrimSpace(req.Phone) == "" { errors["phone"] = "Phone number is required" } // Validate timezone if strings.TrimSpace(req.Timezone) == "" { errors["timezone"] = "Timezone is required" } // Validate encryption data - these are critical for E2EE // Use user-friendly error messages instead of technical field names if strings.TrimSpace(req.PasswordSalt) == "" { errors["password"] = "Master password is required for encryption setup" } if strings.TrimSpace(req.EncryptedMasterKey) == "" { errors["password"] = "Master password is required for encryption setup" } if strings.TrimSpace(req.PublicKey) == "" { errors["password"] = "Master password is required for encryption setup" } if strings.TrimSpace(req.EncryptedPrivateKey) == "" { errors["password"] = "Master password is required for encryption setup" } if strings.TrimSpace(req.EncryptedRecoveryKey) == "" { errors["password"] = "Master password is required for encryption setup" } if strings.TrimSpace(req.MasterKeyEncryptedWithRecoveryKey) == "" { errors["password"] = "Master password is required for encryption setup" } // Validate KDF parameters - use user-friendly message if req.KDFAlgorithm == "" { errors["password"] = "Master password is required for encryption setup" } if req.KDFIterations <= 0 { errors["password"] = "Master password is required for encryption setup" } // Validate terms agreement if !req.AgreeTermsOfService { errors["agree_terms_of_service"] = "You must agree to the terms of service to register" } // If there are validation errors, return RFC 9457 error if len(errors) > 0 { return httperror.NewValidationError(errors) } return nil }