Raw
  1package invite
  2
  3import (
  4	"context"
  5	"crypto/rand"
  6	"crypto/sha256"
  7	"encoding/base64"
  8	"errors"
  9	"fmt"
 10	"io/fs"
 11	"log/slog"
 12	"os"
 13	"path/filepath"
 14	"slices"
 15	"sync"
 16	"syscall"
 17	"time"
 18
 19	"tug/internal/service/password"
 20	"tug/internal/storage"
 21)
 22
 23const FileName = "bootstrap-invite"
 24
 25const tokenBytes = 32
 26
 27var (
 28	ErrInvalidToken       = errors.New("invite: invitation link is not valid")
 29	ErrUsernameGrammar    = errors.New("invite: username does not match the required grammar")
 30	ErrUsernameTaken      = errors.New("invite: username is taken")
 31	ErrDisplayNameInvalid = errors.New("invite: display name is not valid")
 32	ErrPasswordMismatch   = errors.New("invite: the two passwords differ")
 33)
 34
 35type Config struct {
 36	AccountDomain    string
 37	ReferencedGroups []string
 38	DefaultGroups    []string
 39	AdminGroup       string
 40	TTL              time.Duration
 41	StateDir         string
 42}
 43
 44type Service struct {
 45	store    *storage.Store
 46	cfg      Config
 47	groupsMu sync.RWMutex
 48	logger   *slog.Logger
 49	now      func() time.Time
 50	remove   func(string) error
 51}
 52
 53type Started struct {
 54	Issued    bool
 55	Line      string
 56	Path      string
 57	ExpiresAt time.Time
 58}
 59
 60type Form struct {
 61	Username    string
 62	DisplayName string
 63	Password    string
 64	Confirm     string
 65}
 66
 67func New(store *storage.Store, cfg Config, logger *slog.Logger) *Service {
 68	if store == nil || logger == nil || cfg.AccountDomain == "" || cfg.StateDir == "" || cfg.TTL <= 0 || cfg.AdminGroup == "" {
 69		panic("invite: a service needs a store, a logger, the account domain, the state directory, a positive TTL and the admin group")
 70	}
 71	return &Service{store: store, cfg: cfg, logger: logger, now: time.Now, remove: os.Remove}
 72}
 73
 74func (s *Service) path() string {
 75	return filepath.Join(s.cfg.StateDir, FileName)
 76}
 77
 78func (s *Service) url(token string) string {
 79	return "https://" + s.cfg.AccountDomain + "/invite/" + token
 80}
 81
 82func newToken() (string, []byte, error) {
 83	raw := make([]byte, tokenBytes)
 84	if _, err := rand.Read(raw); err != nil {
 85		return "", nil, fmt.Errorf("invite: generate token: %w", err)
 86	}
 87	sum := sha256.Sum256(raw)
 88	return base64.RawURLEncoding.EncodeToString(raw), sum[:], nil
 89}
 90
 91func tokenHash(token string) ([]byte, bool) {
 92	if len(token) != base64.RawURLEncoding.EncodedLen(tokenBytes) {
 93		return nil, false
 94	}
 95	raw, err := base64.RawURLEncoding.Strict().DecodeString(token)
 96	if err != nil || len(raw) != tokenBytes {
 97		return nil, false
 98	}
 99	sum := sha256.Sum256(raw)
100	return sum[:], true
101}
102
103func (s *Service) Start(ctx context.Context) (Started, error) {
104	if _, err := s.store.EnsureGroups(ctx, s.cfg.ReferencedGroups, "system"); err != nil {
105		return Started{}, fmt.Errorf("invite: create referenced groups: %w", err)
106	}
107	token, hash, err := newToken()
108	if err != nil {
109		return Started{}, err
110	}
111	expires := s.now().Add(s.cfg.TTL).UTC().Truncate(time.Second)
112	issued, err := s.store.ReconcileBootstrap(ctx, hash, expires)
113	if err != nil {
114		return Started{}, fmt.Errorf("invite: reconcile bootstrap: %w", err)
115	}
116	path := s.path()
117	if !issued {
118		if err := s.removeFile(path); err != nil {
119			return Started{}, err
120		}
121		return Started{Path: path}, nil
122	}
123	url := s.url(token)
124	if err := s.writeFile(path, url+"\n"); err != nil {
125		return Started{}, err
126	}
127	line := fmt.Sprintf("tug: bootstrap invitation (valid until %s; also in %s): %s", expires.Format(time.RFC3339), path, url)
128	return Started{Issued: true, Line: line, Path: path, ExpiresAt: expires}, nil
129}
130
131func (s *Service) Sweep(ctx context.Context) error {
132	usersExist, err := s.store.ClearBootstrapIfUsers(ctx)
133	if err != nil {
134		return fmt.Errorf("invite: clear bootstrap: %w", err)
135	}
136	if !usersExist {
137		return nil
138	}
139	return s.removeFile(s.path())
140}
141
142func (s *Service) Check(ctx context.Context, token string) error {
143	hash, ok := tokenHash(token)
144	if !ok {
145		return ErrInvalidToken
146	}
147	if _, err := s.store.CheckRedemption(ctx, hash); err != nil {
148		return s.tokenError(err)
149	}
150	return nil
151}
152
153func (s *Service) RedeemAccount(ctx context.Context, token string, f Form) (Redeemed, error) {
154	hash, ok := tokenHash(token)
155	if !ok {
156		return Redeemed{}, ErrInvalidToken
157	}
158	if _, err := s.store.CheckRedemption(ctx, hash); err != nil {
159		return Redeemed{}, s.tokenError(err)
160	}
161	username, err := storage.NormalizeUsername(f.Username)
162	if err != nil {
163		return Redeemed{}, ErrUsernameGrammar
164	}
165	if err := storage.CheckDisplayName(f.DisplayName); err != nil {
166		return Redeemed{}, ErrDisplayNameInvalid
167	}
168	if err := password.Acceptable(f.Password); err != nil {
169		return Redeemed{}, err
170	}
171	if f.Confirm != f.Password {
172		return Redeemed{}, ErrPasswordMismatch
173	}
174	if _, err := s.store.UserByUsername(ctx, username); err == nil {
175		return Redeemed{}, ErrUsernameTaken
176	} else if !errors.Is(err, storage.ErrNotFound) {
177		return Redeemed{}, fmt.Errorf("invite: look up username: %w", err)
178	}
179	passwordHash, err := password.HashContext(ctx, f.Password)
180	if err != nil {
181		return Redeemed{}, fmt.Errorf("invite: hash password: %w", err)
182	}
183	redeemed, err := s.store.RedeemInvitation(ctx, storage.RedeemInput{
184		TokenHash: hash,
185		User: storage.CreateUserInput{
186			Username:     username,
187			DisplayName:  f.DisplayName,
188			PasswordHash: passwordHash,
189		},
190		DefaultGroups: s.defaultGroups(),
191		AdminGroup:    s.cfg.AdminGroup,
192	})
193	switch {
194	case errors.Is(err, storage.ErrUsernameTaken):
195		return Redeemed{}, ErrUsernameTaken
196	case err != nil:
197		return Redeemed{}, s.tokenError(err)
198	}
199	if err := s.Sweep(ctx); err != nil {
200		s.logger.Error("bootstrap invitation file not removed after a redemption; the next start removes it", "path", s.path(), "err", err)
201	}
202	s.logger.Info("invitation redeemed", "username", redeemed.User.Username, "groups", redeemed.Groups)
203	return Redeemed{Username: redeemed.User.Username, Code: redeemed.Code}, nil
204}
205
206func (s *Service) tokenError(err error) error {
207	if errors.Is(err, storage.ErrBootstrapFirst) {
208		s.logger.Info("ordinary invitation refused: no user exists yet, so the bootstrap invitation must be redeemed first")
209	}
210	if errors.Is(err, storage.ErrInvalidToken) {
211		return ErrInvalidToken
212	}
213	return fmt.Errorf("invite: %w", err)
214}
215
216func (s *Service) removeFile(path string) error {
217	fi, err := os.Lstat(path)
218	if errors.Is(err, fs.ErrNotExist) {
219		return nil
220	}
221	if err != nil {
222		return fmt.Errorf("invite: inspect %s: %w", path, err)
223	}
224	if !fi.Mode().IsRegular() {
225		return fmt.Errorf("invite: %s is not a regular file", path)
226	}
227	if err := s.remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
228		return fmt.Errorf("invite: remove %s: %w", path, err)
229	}
230	return nil
231}
232
233func (s *Service) writeFile(path, content string) error {
234	if err := s.removeFile(path); err != nil {
235		return err
236	}
237	f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0o600)
238	if err != nil {
239		return fmt.Errorf("invite: create %s: %w", path, err)
240	}
241	fi, err := f.Stat()
242	if err == nil && fi.Mode().Perm() != 0o600 {
243		err = fmt.Errorf("mode %v, want 0600", fi.Mode().Perm())
244	}
245	if err == nil {
246		_, err = f.WriteString(content)
247	}
248	if closeErr := f.Close(); err == nil {
249		err = closeErr
250	}
251	if err != nil {
252		os.Remove(path)
253		return fmt.Errorf("invite: write %s: %w", path, err)
254	}
255	return nil
256}
257
258func (s *Service) SetDefaultGroups(ctx context.Context, groups []string) error {
259	if _, err := s.store.EnsureGroups(ctx, groups, "system"); err != nil {
260		return fmt.Errorf("invite: create default groups: %w", err)
261	}
262	s.groupsMu.Lock()
263	defer s.groupsMu.Unlock()
264	s.cfg.DefaultGroups = slices.Clone(groups)
265	return nil
266}
267
268func (s *Service) defaultGroups() []string {
269	s.groupsMu.RLock()
270	defer s.groupsMu.RUnlock()
271	return s.cfg.DefaultGroups
272}