internal/control/loginlink.go
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}