package member import ( "context" "fmt" "os" "time" 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" "github.com/zeromicro/go-zero/core/logx" ) 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.SysNotImplemented("member OTP not configured") } if sc.Notifier == nil { return nil, errb.SysNotImplemented("notifier not configured") } if sc.MemberVerifyRate == nil { return nil, errb.SysNotImplemented("member verify rate 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)) if err := sc.MemberVerifyRate.AssertResendAllowed(ctx, rateKey, time.Duration(cfg.OTP.ResendCooldownSeconds)*time.Second); err != nil { return nil, err } dailyKey := memberdom.GetVerifyDailyRedisKey(actor.TenantID, actor.UID, string(purpose)) if err := sc.MemberVerifyRate.AssertDailyAllowed(ctx, dailyKey, 24*time.Hour, cfg.OTP.DailyVerifyLimit); err != nil { return nil, err } 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, invErr } return nil, sendErr } if os.Getenv("GATEWAY_E2E") == "1" && sc.Redis != nil && sc.Redis.Zero() != nil { key := fmt.Sprintf("e2e:otp:%s", dto.ChallengeID) if setErr := sc.Redis.Zero().SetexCtx(ctx, key, plainCode, dto.ExpiresIn); setErr != nil { logx.WithContext(ctx).Infof("e2e otp mirror skipped: %v", setErr) } } 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.SysNotImplemented("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.SysNotImplemented("member TOTP not configured") } return nil } func actorOrErr(ctx context.Context) (Actor, error) { actor, err := ActorFromContext(ctx) if err != nil { return Actor{}, errb.AuthUnauthorized("missing bearer token or X-Tenant-ID/X-UID headers") } return actor, nil }