krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

main: internal/control/register.go · raw

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