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