207 lines
7.0 KiB
Go
207 lines
7.0 KiB
Go
// Package flow owns signup and account activation, independently of second-factor checks.
|
|
package flow
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
|
account "git.sendico.io/tech/sxchnge/product/customers/store"
|
|
"git.sendico.io/tech/sxchnge/product/notifications"
|
|
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
|
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
|
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
|
"git.sendico.io/tech/sxchnge/shared/database/transaction"
|
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
|
ratelimits "git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
|
"go.mongodb.org/mongo-driver/v2/bson"
|
|
)
|
|
|
|
const Cooldown = time.Minute
|
|
|
|
type SignupFacts interface {
|
|
Ensure(context.Context, *customer.Account) error
|
|
Ready(context.Context, bson.ObjectID) error
|
|
}
|
|
|
|
type Service struct {
|
|
Facts SignupFacts
|
|
Accounts account.DB
|
|
Customers accountservice.AccountService
|
|
Tokens verification.DB
|
|
Transactions transaction.Factory
|
|
Attempts ratelimits.Store
|
|
Sender notifications.Sender
|
|
Secret []byte
|
|
ActivationURL string
|
|
}
|
|
|
|
func (s *Service) Validate() error {
|
|
u, err := url.Parse(s.ActivationURL)
|
|
if err != nil || u.Host == "" || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"))) || u.User != nil || u.Fragment != "" {
|
|
return merrors.InvalidArgument("activation URL must be an absolute HTTPS URL (HTTP allowed on localhost)")
|
|
}
|
|
if len(s.Secret) < 32 {
|
|
return merrors.InvalidArgument("authentication signing secret must be at least 32 bytes")
|
|
}
|
|
return nil
|
|
}
|
|
func (s *Service) key(parts ...string) string {
|
|
h := hmac.New(sha256.New, s.Secret)
|
|
for _, p := range parts {
|
|
h.Write([]byte(p))
|
|
h.Write([]byte{0})
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil))
|
|
}
|
|
func (s *Service) Limit(ctx context.Context, ip, email string) error {
|
|
now := time.Now().UTC()
|
|
start := now.Truncate(time.Hour)
|
|
for _, scope := range []struct {
|
|
kind, value string
|
|
limit int
|
|
}{{"auth_email", email, 10}, {"auth_ip", ip, 30}} {
|
|
count, err := s.Attempts.Increment(ctx, scope.kind, ratelimits.HashScope(scope.kind, scope.value), start, start.Add(time.Hour))
|
|
if err != nil {
|
|
return merrors.ServiceUnavailableWrap(err, "authentication rate limit unavailable")
|
|
}
|
|
if count > scope.limit {
|
|
return verification.ErrorIssuanceRateLimited(scope.kind, start.Add(time.Hour).Sub(now))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key string) (*customer.Account, error) {
|
|
email, err := customer.NormalizeEmail(a.Login)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
a.Login = email
|
|
a.Name = strings.TrimSpace(a.Name)
|
|
if len(a.Name) > 200 || len(a.Password) > 72 {
|
|
return nil, merrors.InvalidArgument("name or password too long")
|
|
}
|
|
if err = s.Limit(ctx, ip, email); err != nil {
|
|
return nil, err
|
|
}
|
|
raw, err := s.Customers.CreateAccountWithActivationKey(ctx, a, s.activationKey(a.Login, key))
|
|
if errors.Is(err, merrors.ErrDataConflict) {
|
|
existing, lookup := s.Accounts.GetByEmail(ctx, email)
|
|
if lookup != nil {
|
|
return nil, lookup
|
|
}
|
|
// A retry after a queue failure may resume only with the original password.
|
|
if existing.Status != customer.AccountPendingVerification || existing.IsArchived() || !existing.MatchPassword(a.Password) {
|
|
return nil, merrors.DataConflict("email already registered")
|
|
}
|
|
a = existing
|
|
} else if err != nil {
|
|
return nil, err
|
|
}
|
|
if s.Facts != nil {
|
|
if err := s.Facts.Ensure(ctx, a); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if raw != "" {
|
|
u, _ := url.Parse(s.ActivationURL)
|
|
q := u.Query()
|
|
q.Set("token", raw)
|
|
u.RawQuery = q.Encode()
|
|
locale := "en"
|
|
if strings.HasPrefix(a.Locale, "ru") {
|
|
locale = "ru"
|
|
}
|
|
err = s.Sender.Send(ctx, notifications.Message{Key: s.key("signup", raw), UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", Parameters: map[string]string{"url": u.String()}, ExpiresAt: time.Now().UTC().Add(23 * time.Hour)})
|
|
} else {
|
|
_, err = s.sendActivation(ctx, a, key)
|
|
}
|
|
return a, err
|
|
}
|
|
func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) error {
|
|
email, err := customer.NormalizeEmail(email)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = s.Limit(ctx, ip, email); err != nil {
|
|
return err
|
|
}
|
|
a, err := s.Accounts.GetByEmail(ctx, email)
|
|
if errors.Is(err, merrors.ErrNoData) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
|
return nil
|
|
}
|
|
if s.Facts != nil {
|
|
if err := s.Facts.Ensure(ctx, a); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
_, err = s.sendActivation(ctx, a, key)
|
|
return err
|
|
}
|
|
func (s *Service) activationKey(email, key string) string {
|
|
return s.key(email, string(verificationmodel.PurposeAccountActivation), "", key)
|
|
}
|
|
func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key string) (time.Time, error) {
|
|
if strings.TrimSpace(key) == "" || len(key) > 128 {
|
|
return time.Time{}, merrors.InvalidArgument("idempotency key required")
|
|
}
|
|
secretKey := s.activationKey(a.Login, key)
|
|
req := verification.NewLinkRequest(a.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24 * time.Hour).WithCooldown(Cooldown).WithIdempotencyKey(secretKey)
|
|
req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, time.Now().UTC()))
|
|
result, err := s.Tokens.CreateWithResult(ctx, req)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
if !result.ExpiresAt.After(time.Now()) {
|
|
return time.Time{}, verification.ErorrTokenExpired()
|
|
}
|
|
locale := "en"
|
|
if strings.HasPrefix(strings.ToLower(a.Locale), "ru") {
|
|
locale = "ru"
|
|
}
|
|
u, _ := url.Parse(s.ActivationURL)
|
|
q := u.Query()
|
|
q.Set("token", result.VerificationToken)
|
|
u.RawQuery = q.Encode()
|
|
err = s.Sender.Send(ctx, notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"url": u.String()}})
|
|
return result.ExpiresAt, err
|
|
}
|
|
func (s *Service) Activate(ctx context.Context, raw string) error {
|
|
if len(raw) != 43 {
|
|
return verification.ErorrTokenNotFound()
|
|
}
|
|
_, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
|
token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
a := &customer.Account{}
|
|
if err = s.Accounts.Get(tx, token.AccountRef, a); err != nil {
|
|
return nil, err
|
|
}
|
|
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
|
return nil, merrors.Unauthorized("account cannot be activated")
|
|
}
|
|
if s.Facts != nil {
|
|
if err := s.Facts.Ready(tx, a.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive))
|
|
})
|
|
return err
|
|
}
|