internal/control/loginlink.go

bd5cf5d7d1f34fa780660fd7562b9ffd9746ee27
gitbay/internal/control/loginlink.go history · blame · raw

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