internal/control/loginlink.go

3c0c20da787d91bcad19aec3b5e1b5c4ac5a4745
gitbay/internal/control/loginlink.go history · blame · raw

110 lines · 3838 bytes

  1package control
  2
  3import (
  4	"fmt"
  5	"strings"
  6	"time"
  7
  8	"gitbay.org/gitbay/internal/config"
  9	"gitbay.org/gitbay/internal/store"
 10)
 11
 12// maxLoginLinksPerHour bounds what one account's address can be made to
 13// receive. It matches maxEmailAddsPerHour: enough for a person who mistypes
 14// and retries, nothing for a script. The counter is shared with SSH-minted
 15// links, not just these: CountLoginTokensSince counts every row in
 16// login_tokens, and "web login" over SSH inserts into that same table
 17// without consulting this bound, so five "ssh git@host web login" calls in
 18// an hour also spend an account's budget here.
 19const maxLoginLinksPerHour = 5
 20
 21// loginLinkTTL is longer than the five minutes an SSH-minted link gets.
 22// That one is pasted from a terminal already open; this one has to survive
 23// delivery and someone noticing the mail.
 24const loginLinkTTL = 15 * time.Minute
 25
 26// RequestLoginLink mails a one-time login link to the account named by
 27// identifier, which is a username or a verified email address.
 28//
 29// It is not a registered command: the caller is an unauthenticated web
 30// request, and commands run as c.User. RegisterAccount is exported for the
 31// same reason.
 32//
 33// The returned error is for the server log only. Nothing about the outcome
 34// may reach the caller — that a request found an account, found one without
 35// a verified address, or found nothing at all must be indistinguishable, or
 36// the endpoint answers "does this person have an account here?" to anyone
 37// who asks. Every miss returns nil.
 38func RequestLoginLink(cfg config.Config, st *store.Store, identifier string) error {
 39	if cfg.Web.Mode != "accounts" || cfg.Mail.SMTPHost == "" {
 40		return nil
 41	}
 42	identifier = strings.TrimSpace(identifier)
 43	if identifier == "" {
 44		return nil
 45	}
 46
 47	var user store.User
 48	var address string
 49	if strings.Contains(identifier, "@") {
 50		id, ok := st.UserIDByVerifiedEmail(identifier)
 51		if !ok {
 52			return nil
 53		}
 54		u, err := st.UserByID(id)
 55		if err != nil {
 56			return nil
 57		}
 58		user, address = u, identifier
 59	} else {
 60		u, err := st.UserByUsername(identifier)
 61		if err != nil {
 62			return nil
 63		}
 64		addr, err := st.PreferredVerifiedEmail(u.ID)
 65		if err != nil || addr == "" {
 66			return nil
 67		}
 68		user, address = u, addr
 69	}
 70	// Dispatch refuses both of these, so a session they reach only renders
 71	// read paths — which is the whole of what suspension prevents, and more
 72	// than pendingAllowed grants an unverified account. Returning nil rather
 73	// than an error keeps the response identical to a miss.
 74	if user.Disabled || user.Pending {
 75		return nil
 76	}
 77
 78	n, err := st.CountLoginTokensSince(user.ID, time.Now().Add(-time.Hour))
 79	if err != nil {
 80		return err
 81	}
 82	if n >= maxLoginLinksPerHour {
 83		return nil
 84	}
 85
 86	token, hash, err := store.NewToken()
 87	if err != nil {
 88		return err
 89	}
 90	if err := st.CreateLoginToken(user.ID, hash, loginLinkTTL); err != nil {
 91		return err
 92	}
 93	host := siteHost(cfg)
 94	body := fmt.Sprintf(
 95		"Someone (hopefully you) asked to log in to %s.\n\n"+
 96			"Open this link within 15 minutes. It works once:\n\n    %s/login?token=%s\n\n"+
 97			"If this wasn't you, ignore this mail. Nothing has changed on the account.\n",
 98		host, strings.TrimSuffix(cfg.Server.SiteURL, "/"), token)
 99	subject := "log in to " + host
100
101	// Queued rather than sent inline: the INSERT is sub-millisecond, the
102	// same order of cost as the miss path's SELECT, so every case — hit,
103	// miss, unverified, throttled — still resolves on the same DB-bound
104	// path. notify.Mailer drains the queue with retries (30s, 60s, 120s,
105	// 240s, then dead-lettered) that top out at 450s, comfortably inside
106	// the 15-minute link TTL, so a retried delivery cannot outlive the
107	// link it carries. Unlike the goroutine this replaces, a crash mid
108	// delivery does not lose the mail.
109	return st.EnqueueMail(address, subject, body)
110}