internal/control/loginlink.go

00033f022fed6fe0b36e1d7a1e2b3f1df6139ccd
gitbay/internal/control/loginlink.go history · blame · raw

114 lines · 3836 bytes

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