221 lines
8.6 KiB
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
|
|
}
|