390 lines
14 KiB
Go
390 lines
14 KiB
Go
// 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(`
|
|
<html>
|
|
<body>
|
|
<h2>Welcome to MapleFile, %s!</h2>
|
|
<p>Thank you for registering. Please verify your email address by entering this code:</p>
|
|
<h1 style="color: #4CAF50; font-size: 32px; letter-spacing: 5px;">%s</h1>
|
|
<p>This code will expire in 24 hours.</p>
|
|
<p>If you didn't create this account, please ignore this email.</p>
|
|
</body>
|
|
</html>
|
|
`, 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
|
|
}
|