package member import ( "context" "time" errs "gateway/internal/library/errors" "gateway/internal/library/errors/code" memberdom "gateway/internal/model/member/domain" "gateway/internal/model/member/domain/enum" domusecase "gateway/internal/model/member/domain/usecase" notifenum "gateway/internal/model/notification/domain/enum" notifuc "gateway/internal/model/notification/domain/usecase" "gateway/internal/svc" "gateway/internal/types" ) var errb = errs.For(code.Facade) func startVerification( ctx context.Context, sc *svc.ServiceContext, actor Actor, purpose enum.OTPPurpose, channel notifenum.Channel, kind notifenum.NotifyKind, target string, ) (*types.VerificationStartData, error) { if sc.MemberOTP == nil { return nil, errb.SysInternal("member OTP not configured") } if sc.Notifier == nil { return nil, errb.SysInternal("notifier not configured") } if target == "" { return nil, errb.InputMissingRequired("target is required") } cfg := sc.Config.Member.Defaults() rateKey := memberdom.GetVerifyRateRedisKey(actor.TenantID, actor.UID, string(purpose)) ok, err := sc.MemberVerifyRate.TryResendLock(ctx, rateKey, time.Duration(cfg.OTP.ResendCooldownSeconds)*time.Second) if err != nil { return nil, errb.SysInternal("rate limit check failed").WithCause(err) } if !ok { return nil, errb.AuthForbidden("resend cooldown active").WithCause(memberdom.ErrResendCooldown) } dailyKey := memberdom.GetVerifyDailyRedisKey(actor.TenantID, actor.UID, string(purpose)) count, err := sc.MemberVerifyRate.IncrDaily(ctx, dailyKey, 24*time.Hour) if err != nil { return nil, errb.SysInternal("daily limit check failed").WithCause(err) } if count > int64(cfg.OTP.DailyVerifyLimit) { return nil, errb.AuthForbidden("daily verification limit exceeded").WithCause(memberdom.ErrDailyLimit) } dto, plainCode, err := sc.MemberOTP.Generate(ctx, &domusecase.GenerateOTPRequest{ TenantID: actor.TenantID, UID: actor.UID, Purpose: purpose, Target: target, }) if err != nil { return nil, err } locale := sc.Config.Notification.DefaultLocale if _, sendErr := sc.Notifier.Send(ctx, ¬ifuc.SendRequest{ TenantID: actor.TenantID, UID: actor.UID, Channel: channel, Kind: kind, Target: target, Locale: locale, Data: map[string]any{"code": plainCode, "expires_in": dto.ExpiresIn}, IdempotencyKey: dto.ChallengeID, DoNotPersistBody: true, Severity: notifenum.SeverityInfo, }); sendErr != nil { if invErr := sc.MemberOTP.Invalidate(ctx, dto.ChallengeID); invErr != nil { return nil, errb.SysInternal("invalidate otp after send failure").WithCause(invErr) } return nil, sendErr } return &types.VerificationStartData{ ChallengeID: dto.ChallengeID, ExpiresIn: dto.ExpiresIn, }, nil } func confirmVerification( ctx context.Context, sc *svc.ServiceContext, actor Actor, req *types.VerificationConfirmReq, purpose enum.OTPPurpose, setVerified func(context.Context, string, string, string) error, ) error { if sc.MemberOTP == nil || sc.MemberProfile == nil { return errb.SysInternal("member module not configured") } if req == nil || req.ChallengeID == "" || req.Code == "" { return errb.InputMissingRequired("challenge_id and code are required") } target, err := sc.MemberOTP.Verify(ctx, &domusecase.VerifyOTPRequest{ TenantID: actor.TenantID, UID: actor.UID, ChallengeID: req.ChallengeID, Code: req.Code, Purpose: purpose, }) if err != nil { return err } return setVerified(ctx, actor.TenantID, actor.UID, target) } func requireTOTP(sc *svc.ServiceContext) error { if sc.MemberTOTP == nil { return errb.SysInternal("member TOTP not configured") } return nil } func actorOrErr(ctx context.Context) (Actor, error) { actor, err := ActorFromContext(ctx) if err != nil { return Actor{}, errb.AuthForbidden(err.Error()) } return actor, nil }