179 lines
4.9 KiB
Go
179 lines
4.9 KiB
Go
package logic
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
|
|
smsentity "spark-server/internal/module/sms/entity"
|
|
userentity "spark-server/internal/module/user/entity"
|
|
"spark-server/internal/pkg/errs"
|
|
"spark-server/internal/pkg/i18n"
|
|
"spark-server/internal/pkg/reqctx"
|
|
)
|
|
|
|
// SendCodeInput is the HTTP body for POST /v1/sms/codes.
|
|
type SendCodeInput struct {
|
|
Purpose smsentity.Purpose `json:"purpose" validate:"required,oneof=1 2" label:"field.verification_purpose" errmsg:"required:validate.sms.purpose_required;oneof:validate.sms.purpose_invalid"`
|
|
Target string `json:"target" validate:"required" label:"field.verification_target" errmsg:"required:validate.sms.target_required"`
|
|
}
|
|
|
|
type SentCode struct {
|
|
ExpiresIn int64 `json:"expires_in"`
|
|
}
|
|
|
|
func (s *Service) Send(ctx context.Context, in SendCodeInput) (*SentCode, error) {
|
|
if s == nil || s.store == nil {
|
|
return nil, errs.ErrAvailable
|
|
}
|
|
ch, target, ok := smsentity.ParseTarget(in.Target)
|
|
if !ok {
|
|
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeySMSTargetInvalid, nil))
|
|
}
|
|
switch ch {
|
|
case smsentity.ChannelEmail:
|
|
return s.sendEmail(ctx, in.Purpose, target, reqctxUserID(ctx))
|
|
case smsentity.ChannelPhone:
|
|
return s.sendPhone(ctx, in.Purpose, target, reqctxUserID(ctx))
|
|
default:
|
|
return nil, errs.ErrInvalidArgument
|
|
}
|
|
}
|
|
|
|
func (s *Service) sendEmail(ctx context.Context, purpose smsentity.Purpose, email string, userID int64) (*SentCode, error) {
|
|
if s == nil || s.store == nil {
|
|
return nil, errs.ErrAvailable
|
|
}
|
|
email = userentity.NormalizeEmail(email)
|
|
if email == "" {
|
|
return nil, errs.ErrInvalidArgument
|
|
}
|
|
if err := s.guardPurpose(ctx, purpose, smsentity.ChannelEmail, email, userID); err != nil {
|
|
return nil, err
|
|
}
|
|
if skipSendEmail(email) {
|
|
return &SentCode{ExpiresIn: CodeTTLSeconds}, nil
|
|
}
|
|
key := storeKey(smsentity.ChannelEmail, purpose, email)
|
|
ok, err := s.store.SetCooldown(ctx, key, defaultCodeCooldownSeconds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
return nil, errs.ErrRateLimited
|
|
}
|
|
code, err := randomDigits(6)
|
|
if err != nil {
|
|
return nil, errs.ErrInner.WithError("sms-code", err)
|
|
}
|
|
if s.email != nil {
|
|
lang := reqctx.Language(ctx)
|
|
if err := s.email.SendOTP(ctx, email, purpose, code, lang); err != nil {
|
|
_ = s.store.Del(ctx, key)
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := s.store.Put(ctx, key, code, defaultCodeTTLSeconds); err != nil {
|
|
return nil, err
|
|
}
|
|
return &SentCode{ExpiresIn: CodeTTLSeconds}, nil
|
|
}
|
|
|
|
func (s *Service) sendPhone(ctx context.Context, purpose smsentity.Purpose, phone string, userID int64) (*SentCode, error) {
|
|
if s == nil || s.store == nil {
|
|
return nil, errs.ErrAvailable
|
|
}
|
|
phone = smsentity.NormalizePhone(phone)
|
|
if phone == "" {
|
|
return nil, errs.ErrInvalidArgument
|
|
}
|
|
if err := s.guardPurpose(ctx, purpose, smsentity.ChannelPhone, phone, userID); err != nil {
|
|
return nil, err
|
|
}
|
|
if skipSendPhone(phone) {
|
|
return &SentCode{ExpiresIn: CodeTTLSeconds}, nil
|
|
}
|
|
key := storeKey(smsentity.ChannelPhone, purpose, phone)
|
|
ok, err := s.store.SetCooldown(ctx, key, defaultCodeCooldownSeconds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
return nil, errs.ErrRateLimited
|
|
}
|
|
code, err := randomDigits(6)
|
|
if err != nil {
|
|
return nil, errs.ErrInner.WithError("sms-code", err)
|
|
}
|
|
if err := s.dispatchPhone(ctx, phone, purpose, code); err != nil {
|
|
_ = s.store.Del(ctx, key)
|
|
return nil, err
|
|
}
|
|
if err := s.store.Put(ctx, key, code, defaultCodeTTLSeconds); err != nil {
|
|
return nil, err
|
|
}
|
|
return &SentCode{ExpiresIn: CodeTTLSeconds}, nil
|
|
}
|
|
|
|
func (s *Service) dispatchPhone(ctx context.Context, phone string, purpose smsentity.Purpose, code string) error {
|
|
if len(s.phone) == 0 {
|
|
return errs.ErrAvailable
|
|
}
|
|
carrier := smsentity.DetectCarrier(phone)
|
|
for _, p := range s.phone {
|
|
if p == nil {
|
|
continue
|
|
}
|
|
if p.Carrier() == carrier {
|
|
return p.SendOTP(ctx, phone, purpose, code)
|
|
}
|
|
}
|
|
for _, p := range s.phone {
|
|
if p == nil {
|
|
continue
|
|
}
|
|
if p.Carrier() == smsentity.CarrierMobile {
|
|
return p.SendOTP(ctx, phone, purpose, code)
|
|
}
|
|
}
|
|
return errs.ErrAvailable
|
|
}
|
|
|
|
func (s *Service) guardPurpose(ctx context.Context, purpose smsentity.Purpose, ch smsentity.Channel, target string, userID int64) error {
|
|
if !purpose.Valid() {
|
|
return errs.ErrInvalidArgument
|
|
}
|
|
if purpose != smsentity.PurposeAccountDelete {
|
|
return nil
|
|
}
|
|
if userID <= 0 {
|
|
return errs.ErrUnauthorized
|
|
}
|
|
if s.accounts == nil {
|
|
return errs.ErrAvailable
|
|
}
|
|
bound, err := s.accounts.EmailByUserID(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
bound = userentity.NormalizeEmail(bound)
|
|
if bound == "" {
|
|
return errs.ErrForbidden
|
|
}
|
|
if ch == smsentity.ChannelEmail {
|
|
if userentity.NormalizeEmail(target) != bound {
|
|
return errs.ErrForbidden
|
|
}
|
|
return nil
|
|
}
|
|
_ = strings.TrimSpace(target)
|
|
return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeySMSDeletePhoneUnsupported, nil))
|
|
}
|
|
|
|
func reqctxUserID(ctx context.Context) int64 {
|
|
rc := reqctx.From(ctx)
|
|
if rc == nil {
|
|
return 0
|
|
}
|
|
return rc.UserID
|
|
}
|