monorepo/cloud/maplefile-backend/internal/service/auth/verify_ott.go

221 lines
8.6 KiB
Go

// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/auth/verify_ott.go
package auth
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"
"strings"
"time"
"github.com/awnumar/memguard"
"github.com/gocql/gocql"
"go.uber.org/zap"
uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/security/crypto"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/storage/cache/cassandracache"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/transaction"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
)
type VerifyOTTRequestDTO struct {
Email string `json:"email"`
OTT string `json:"ott"`
}
type VerifyOTTResponseDTO struct {
Message string `json:"message"`
ChallengeID string `json:"challengeId"`
EncryptedChallenge string `json:"encryptedChallenge"`
Salt string `json:"salt"`
EncryptedMasterKey string `json:"encryptedMasterKey"`
EncryptedPrivateKey string `json:"encryptedPrivateKey"`
PublicKey string `json:"publicKey"`
// KDFAlgorithm specifies which key derivation algorithm to use.
// Values: "PBKDF2-SHA256" (web frontend) or "argon2id" (native app legacy)
KDFAlgorithm string `json:"kdfAlgorithm"`
}
type VerifyOTTService interface {
Execute(ctx context.Context, req *VerifyOTTRequestDTO) (*VerifyOTTResponseDTO, error)
}
type verifyOTTServiceImpl struct {
logger *zap.Logger
userGetByEmailUC uc_user.UserGetByEmailUseCase
cache cassandracache.CassandraCacher
}
func NewVerifyOTTService(
logger *zap.Logger,
userGetByEmailUC uc_user.UserGetByEmailUseCase,
cache cassandracache.CassandraCacher,
) VerifyOTTService {
return &verifyOTTServiceImpl{
logger: logger.Named("VerifyOTTService"),
userGetByEmailUC: userGetByEmailUC,
cache: cache,
}
}
func (s *verifyOTTServiceImpl) Execute(ctx context.Context, req *VerifyOTTRequestDTO) (*VerifyOTTResponseDTO, error) {
// Validate request
if err := s.validateVerifyOTTRequest(req); err != nil {
return nil, err // Returns RFC 9457 ProblemDetail
}
// Create SAGA for OTT verification workflow
saga := transaction.NewSaga("verify-ott", s.logger)
s.logger.Info("starting OTT verification")
// Step 1: Normalize email
email := strings.ToLower(strings.TrimSpace(req.Email))
// Step 2: Get OTT from cache
cacheKey := fmt.Sprintf("ott:%s", email)
cachedOTT, err := s.cache.Get(ctx, cacheKey)
if err != nil || cachedOTT == nil {
s.logger.Warn("OTT not found in cache", zap.String("email", validation.MaskEmail(email)))
return nil, httperror.NewUnauthorizedError("Invalid or expired verification code. Please request a new code.")
}
defer memguard.WipeBytes(cachedOTT) // SECURITY: Wipe OTT from memory after use
// Step 3: Verify OTT matches using constant-time comparison
// CWE-208: Prevents timing attacks by ensuring comparison takes same time regardless of match
if subtle.ConstantTimeCompare(cachedOTT, []byte(req.OTT)) != 1 {
s.logger.Warn("OTT mismatch", zap.String("email", validation.MaskEmail(email)))
return nil, httperror.NewUnauthorizedError("Incorrect verification code. Please check the code and try again.")
}
// Step 4: Get user to retrieve encrypted keys (read-only, no compensation)
user, err := s.userGetByEmailUC.Execute(ctx, email)
if err != nil || user == nil {
s.logger.Error("User not found after OTT verification", zap.String("email", validation.MaskEmail(email)), zap.Error(err))
return nil, httperror.NewUnauthorizedError("User account not found. Please contact support.")
}
// Step 5: Generate random challenge (32 bytes)
challenge := make([]byte, 32)
if _, err := rand.Read(challenge); err != nil {
s.logger.Error("Failed to generate challenge", zap.Error(err))
return nil, httperror.NewInternalServerError("Failed to generate security challenge. Please try again.")
}
defer memguard.WipeBytes(challenge) // SECURITY: Wipe challenge from memory after use
// Step 6: Generate challenge ID
challengeID := gocql.TimeUUID().String()
// Step 7: Store challenge in cache FIRST (compensate: delete challenge)
// CRITICAL: Store challenge before deleting OTT to prevent lockout
challengeKey := fmt.Sprintf("challenge:%s", challengeID)
if err := s.cache.SetWithExpiry(ctx, challengeKey, challenge, 5*time.Minute); err != nil {
s.logger.Error("Failed to store challenge", zap.Error(err))
return nil, httperror.NewInternalServerError("Failed to store security challenge. Please try again.")
}
// Register compensation: delete challenge if OTT deletion fails
challengeKeyCaptured := challengeKey
saga.AddCompensation(func(ctx context.Context) error {
s.logger.Info("compensating: deleting challenge",
zap.String("challenge_key", challengeKeyCaptured))
return s.cache.Delete(ctx, challengeKeyCaptured)
})
// Step 8: Delete OTT from cache (one-time use) (compensate: restore OTT)
cacheKeyCaptured := cacheKey
cachedOTTCaptured := cachedOTT
if err := s.cache.Delete(ctx, cacheKey); err != nil {
s.logger.Error("Failed to delete OTT",
zap.String("cache_key", cacheKey),
zap.Error(err))
// Trigger compensation: Delete challenge
saga.Rollback(ctx)
return nil, httperror.NewInternalServerError("Verification failed. Please try again.")
}
// Register compensation: restore OTT with reduced TTL (5 minutes for retry)
saga.AddCompensation(func(ctx context.Context) error {
s.logger.Info("compensating: restoring OTT",
zap.String("cache_key", cacheKeyCaptured))
// Restore with reduced TTL (5 minutes) to allow user retry
return s.cache.SetWithExpiry(ctx, cacheKeyCaptured, cachedOTTCaptured, 5*time.Minute)
})
// Encrypt the challenge with the user's public key using NaCl sealed box
encryptedChallengeBytes, err := crypto.EncryptWithPublicKey(challenge, user.SecurityData.PublicKey.Key)
if err != nil {
s.logger.Error("Failed to encrypt challenge", zap.Error(err))
return nil, httperror.NewInternalServerError("Failed to encrypt security challenge. Please try again.")
}
defer memguard.WipeBytes(encryptedChallengeBytes) // SECURITY: Wipe encrypted challenge after encoding
encryptedChallenge := base64.StdEncoding.EncodeToString(encryptedChallengeBytes)
s.logger.Info("OTT verified successfully",
zap.String("email", validation.MaskEmail(email)),
zap.String("challenge_id", challengeID),
zap.String("challenge_key", challengeKey[:16]+"...")) // Log prefix for security
// Prepare user's encrypted keys for frontend
salt := base64.StdEncoding.EncodeToString(user.SecurityData.PasswordSalt)
encryptedMasterKey := base64.StdEncoding.EncodeToString(append(user.SecurityData.EncryptedMasterKey.Nonce, user.SecurityData.EncryptedMasterKey.Ciphertext...))
encryptedPrivateKey := base64.StdEncoding.EncodeToString(append(user.SecurityData.EncryptedPrivateKey.Nonce, user.SecurityData.EncryptedPrivateKey.Ciphertext...))
publicKey := base64.StdEncoding.EncodeToString(user.SecurityData.PublicKey.Key)
// Get KDF algorithm from user's security data
kdfAlgorithm := user.SecurityData.KDFParams.Algorithm
if kdfAlgorithm == "" {
// Default to argon2id for backward compatibility with old accounts
kdfAlgorithm = "argon2id"
}
return &VerifyOTTResponseDTO{
Message: "OTT verified. Please decrypt the challenge with your master key.",
ChallengeID: challengeID,
EncryptedChallenge: encryptedChallenge,
Salt: salt,
EncryptedMasterKey: encryptedMasterKey,
EncryptedPrivateKey: encryptedPrivateKey,
PublicKey: publicKey,
KDFAlgorithm: kdfAlgorithm,
}, nil
}
// validateVerifyOTTRequest validates the verify OTT request.
// Returns RFC 9457 ProblemDetail error with field-specific errors.
func (s *verifyOTTServiceImpl) validateVerifyOTTRequest(req *VerifyOTTRequestDTO) error {
errors := make(map[string]string)
// Validate email using shared validation utility
if errMsg := validation.ValidateEmail(req.Email); errMsg != "" {
errors["email"] = errMsg
}
// Validate OTT code
ott := strings.TrimSpace(req.OTT)
if ott == "" {
errors["ott"] = "Verification code is required"
} else if len(ott) != 8 {
errors["ott"] = "Verification code must be 8 digits"
} else {
// Check if all characters are digits
for _, c := range ott {
if c < '0' || c > '9' {
errors["ott"] = "Verification code must contain only numbers"
break
}
}
}
// If there are validation errors, return RFC 9457 error
if len(errors) > 0 {
return httperror.NewValidationError(errors)
}
return nil
}