internal/control/register.go

4f3bd4893bc9923a02b86ae2cbae1fb17e019d2c
gitbay/internal/control/register.go history · blame · raw

172 lines · 6099 bytes

  1package control
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"strings"
  8	"time"
  9
 10	"golang.org/x/crypto/ssh"
 11
 12	"gitbay.org/gitbay/internal/config"
 13	"gitbay.org/gitbay/internal/mail"
 14	"gitbay.org/gitbay/internal/policy"
 15	"gitbay.org/gitbay/internal/protocol"
 16	"gitbay.org/gitbay/internal/store"
 17)
 18
 19func init() {
 20	register(Command{Path: []string{"register"},
 21		Summary: "create an account (only meaningful for unregistered keys)",
 22		Run: func(c *Ctx, args []string) int {
 23			return c.fail(protocol.ExitUsage,
 24				"this SSH key already belongs to %s. To register a new account, connect with the key it should use:\n  ssh -F /dev/null -i <newkey> git@<host> register ...",
 25				c.User.Username)
 26		}})
 27	register(Command{Path: []string{"email", "add"},
 28		Summary: "add an address and mail a verification code: email add <address>", Run: runEmailAdd})
 29	register(Command{Path: []string{"email", "verify"},
 30		Summary: "confirm a verification code: email verify <code>", Run: runEmailVerify})
 31}
 32
 33func siteHost(cfg config.Config) string {
 34	h := strings.TrimPrefix(strings.TrimPrefix(cfg.Server.SiteURL, "https://"), "http://")
 35	return strings.TrimSuffix(h, "/")
 36}
 37
 38func sendVerification(cfg config.Config, st *store.Store, userID int64, address string) error {
 39	code, hash, err := store.NewToken()
 40	if err != nil {
 41		return err
 42	}
 43	if err := st.CreateEmailToken(userID, address, hash, 24*time.Hour); err != nil {
 44		return err
 45	}
 46	body := fmt.Sprintf(
 47		"Someone (hopefully you) added this address to an account on %s.\n\n"+
 48			"To verify it, run:\n\n    ssh git@%s email verify %s\n\n"+
 49			"The code expires in 24 hours. If this wasn't you, ignore this mail.\n",
 50		siteHost(cfg), siteHost(cfg), code)
 51	return mail.Send(cfg, address, "verify your email on "+siteHost(cfg), body)
 52}
 53
 54func runEmailAdd(c *Ctx, args []string) int {
 55	if len(args) != 1 || !strings.Contains(args[0], "@") {
 56		return c.fail(protocol.ExitUsage, "usage: email add <address>")
 57	}
 58	if c.Cfg.Mail.SMTPHost == "" {
 59		return c.fail(protocol.ExitFailure, "this instance has no SMTP configured; ask an admin to verify the address (gitbayd admin email verify)")
 60	}
 61	if err := c.Store.AddEmail(c.User.ID, args[0], "", false); err != nil {
 62		return c.fail(protocol.ExitFailure, "%v", err)
 63	}
 64	if err := sendVerification(c.Cfg, c.Store, c.User.ID, args[0]); err != nil {
 65		return c.fail(protocol.ExitFailure, "sending verification mail: %v", err)
 66	}
 67	return c.emit(map[string]string{"address": args[0], "status": "verification_sent"}, func(w io.Writer) {
 68		fmt.Fprintf(w, "verification code sent to %s\n", args[0])
 69	})
 70}
 71
 72func runEmailVerify(c *Ctx, args []string) int {
 73	if len(args) != 1 {
 74		return c.fail(protocol.ExitUsage, "usage: email verify <code>")
 75	}
 76	address, err := c.Store.ConsumeEmailToken(c.User.ID, store.HashToken(args[0]))
 77	if err != nil {
 78		if errors.Is(err, store.ErrNotFound) {
 79			return c.fail(protocol.ExitUsage, "that code is invalid, expired, or already used")
 80		}
 81		return c.fail(protocol.ExitFailure, "%v", err)
 82	}
 83	if err := c.Store.VerifyEmail(c.User.ID, address, "smtp"); err != nil {
 84		return c.fail(protocol.ExitFailure, "%v", err)
 85	}
 86	if err := c.Store.ClearPending(c.User.ID); err != nil {
 87		return c.fail(protocol.ExitFailure, "%v", err)
 88	}
 89	return c.emit(map[string]string{"address": address, "status": "verified"}, func(w io.Writer) {
 90		fmt.Fprintf(w, "%s verified; your account is active\n", address)
 91	})
 92}
 93
 94// RunRegister handles the one command an UNAUTHENTICATED key may run. It is
 95// dispatched outside the normal registry: the caller has already checked
 96// that registration is enabled and that argv[0] == "register".
 97func RunRegister(cfg config.Config, st *store.Store, pub ssh.PublicKey, argv []string,
 98	stdout, stderr io.Writer) int {
 99	var username, email, invite string
100	args := argv[1:]
101	for i := 0; i < len(args); i++ {
102		switch args[i] {
103		case "--username", "--email", "--invite":
104			if i+1 >= len(args) {
105				fmt.Fprintf(stderr, "%s requires a value\n", args[i])
106				return protocol.ExitUsage
107			}
108			switch args[i] {
109			case "--username":
110				username = args[i+1]
111			case "--email":
112				email = args[i+1]
113			case "--invite":
114				invite = args[i+1]
115			}
116			i++
117		default:
118			fmt.Fprintf(stderr, "unexpected argument %q\n", args[i])
119			return protocol.ExitUsage
120		}
121	}
122	fail := func(code int, format string, a ...any) int {
123		fmt.Fprintf(stderr, format+"\n", a...)
124		return code
125	}
126	if username == "" {
127		return fail(protocol.ExitUsage, "usage: register --username <name> --email <address> | register --username <name> --invite <code>")
128	}
129	if err := policy.ValidateOwnerName(username); err != nil {
130		return fail(protocol.ExitUsage, "%v", err)
131	}
132
133	fp := ssh.FingerprintSHA256(pub)
134	switch cfg.Registration.Mode {
135	case "invite":
136		if invite == "" {
137			return fail(protocol.ExitDenied, "this instance is invite-only: register --username <name> --invite <code>")
138		}
139		// One transaction: a failure at any step leaves the invite
140		// redeemable and no partial account behind.
141		_, err := st.RedeemInvite(store.HashToken(invite), username, fp, pub.Type(), pub.Marshal())
142		if err != nil {
143			if errors.Is(err, store.ErrNotFound) {
144				return fail(protocol.ExitDenied, "that invite is invalid or already used")
145			}
146			return fail(protocol.ExitUsage, "%v", err)
147		}
148		fmt.Fprintf(stdout, "welcome, %s — your account is active\n", username)
149		return protocol.ExitOK
150
151	case "open":
152		if email == "" || !strings.Contains(email, "@") {
153			return fail(protocol.ExitUsage, "usage: register --username <name> --email <address>")
154		}
155		uid, err := st.RegisterOpen(username, email, fp, pub.Type(), pub.Marshal())
156		if err != nil {
157			return fail(protocol.ExitUsage, "%v", err)
158		}
159		if err := sendVerification(cfg, st, uid, email); err != nil {
160			return fail(protocol.ExitFailure, "sending verification mail: %v", err)
161		}
162		fmt.Fprintf(stdout,
163			"account %s created. A verification code was sent to %s.\nActivate with:\n\n    ssh git@%s email verify <code>\n",
164			username, email, siteHost(cfg))
165		return protocol.ExitOK
166
167	default:
168		return fail(protocol.ExitDenied, "registration is closed on this instance")
169	}
170}
171
172