internal/control/loginlink.go

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

110 lines · 3851 bytes

3 symbols in this file
  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	// An account scheduled for deletion may still have a link: signing in
 73	// is how its owner cancels.
 74	if (user.Disabled && user.DeleteAfter == "") || 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}