package invite import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "errors" "fmt" "io/fs" "log/slog" "os" "path/filepath" "slices" "sync" "syscall" "time" "tug/internal/service/password" "tug/internal/storage" ) const FileName = "bootstrap-invite" const tokenBytes = 32 var ( ErrInvalidToken = errors.New("invite: invitation link is not valid") ErrUsernameGrammar = errors.New("invite: username does not match the required grammar") ErrUsernameTaken = errors.New("invite: username is taken") ErrDisplayNameInvalid = errors.New("invite: display name is not valid") ErrPasswordMismatch = errors.New("invite: the two passwords differ") ) type Config struct { AccountDomain string ReferencedGroups []string DefaultGroups []string AdminGroup string TTL time.Duration StateDir string } type Service struct { store *storage.Store cfg Config groupsMu sync.RWMutex logger *slog.Logger now func() time.Time remove func(string) error } type Started struct { Issued bool Line string Path string ExpiresAt time.Time } type Form struct { Username string DisplayName string Password string Confirm string } func New(store *storage.Store, cfg Config, logger *slog.Logger) *Service { if store == nil || logger == nil || cfg.AccountDomain == "" || cfg.StateDir == "" || cfg.TTL <= 0 || cfg.AdminGroup == "" { panic("invite: a service needs a store, a logger, the account domain, the state directory, a positive TTL and the admin group") } return &Service{store: store, cfg: cfg, logger: logger, now: time.Now, remove: os.Remove} } func (s *Service) path() string { return filepath.Join(s.cfg.StateDir, FileName) } func (s *Service) url(token string) string { return "https://" + s.cfg.AccountDomain + "/invite/" + token } func newToken() (string, []byte, error) { raw := make([]byte, tokenBytes) if _, err := rand.Read(raw); err != nil { return "", nil, fmt.Errorf("invite: generate token: %w", err) } sum := sha256.Sum256(raw) return base64.RawURLEncoding.EncodeToString(raw), sum[:], nil } func tokenHash(token string) ([]byte, bool) { if len(token) != base64.RawURLEncoding.EncodedLen(tokenBytes) { return nil, false } raw, err := base64.RawURLEncoding.Strict().DecodeString(token) if err != nil || len(raw) != tokenBytes { return nil, false } sum := sha256.Sum256(raw) return sum[:], true } func (s *Service) Start(ctx context.Context) (Started, error) { if _, err := s.store.EnsureGroups(ctx, s.cfg.ReferencedGroups, "system"); err != nil { return Started{}, fmt.Errorf("invite: create referenced groups: %w", err) } token, hash, err := newToken() if err != nil { return Started{}, err } expires := s.now().Add(s.cfg.TTL).UTC().Truncate(time.Second) issued, err := s.store.ReconcileBootstrap(ctx, hash, expires) if err != nil { return Started{}, fmt.Errorf("invite: reconcile bootstrap: %w", err) } path := s.path() if !issued { if err := s.removeFile(path); err != nil { return Started{}, err } return Started{Path: path}, nil } url := s.url(token) if err := s.writeFile(path, url+"\n"); err != nil { return Started{}, err } line := fmt.Sprintf("tug: bootstrap invitation (valid until %s; also in %s): %s", expires.Format(time.RFC3339), path, url) return Started{Issued: true, Line: line, Path: path, ExpiresAt: expires}, nil } func (s *Service) Sweep(ctx context.Context) error { usersExist, err := s.store.ClearBootstrapIfUsers(ctx) if err != nil { return fmt.Errorf("invite: clear bootstrap: %w", err) } if !usersExist { return nil } return s.removeFile(s.path()) } func (s *Service) Check(ctx context.Context, token string) error { hash, ok := tokenHash(token) if !ok { return ErrInvalidToken } if _, err := s.store.CheckRedemption(ctx, hash); err != nil { return s.tokenError(err) } return nil } func (s *Service) RedeemAccount(ctx context.Context, token string, f Form) (Redeemed, error) { hash, ok := tokenHash(token) if !ok { return Redeemed{}, ErrInvalidToken } if _, err := s.store.CheckRedemption(ctx, hash); err != nil { return Redeemed{}, s.tokenError(err) } username, err := storage.NormalizeUsername(f.Username) if err != nil { return Redeemed{}, ErrUsernameGrammar } if err := storage.CheckDisplayName(f.DisplayName); err != nil { return Redeemed{}, ErrDisplayNameInvalid } if err := password.Acceptable(f.Password); err != nil { return Redeemed{}, err } if f.Confirm != f.Password { return Redeemed{}, ErrPasswordMismatch } if _, err := s.store.UserByUsername(ctx, username); err == nil { return Redeemed{}, ErrUsernameTaken } else if !errors.Is(err, storage.ErrNotFound) { return Redeemed{}, fmt.Errorf("invite: look up username: %w", err) } passwordHash, err := password.HashContext(ctx, f.Password) if err != nil { return Redeemed{}, fmt.Errorf("invite: hash password: %w", err) } redeemed, err := s.store.RedeemInvitation(ctx, storage.RedeemInput{ TokenHash: hash, User: storage.CreateUserInput{ Username: username, DisplayName: f.DisplayName, PasswordHash: passwordHash, }, DefaultGroups: s.defaultGroups(), AdminGroup: s.cfg.AdminGroup, }) switch { case errors.Is(err, storage.ErrUsernameTaken): return Redeemed{}, ErrUsernameTaken case err != nil: return Redeemed{}, s.tokenError(err) } if err := s.Sweep(ctx); err != nil { s.logger.Error("bootstrap invitation file not removed after a redemption; the next start removes it", "path", s.path(), "err", err) } s.logger.Info("invitation redeemed", "username", redeemed.User.Username, "groups", redeemed.Groups) return Redeemed{Username: redeemed.User.Username, Code: redeemed.Code}, nil } func (s *Service) tokenError(err error) error { if errors.Is(err, storage.ErrBootstrapFirst) { s.logger.Info("ordinary invitation refused: no user exists yet, so the bootstrap invitation must be redeemed first") } if errors.Is(err, storage.ErrInvalidToken) { return ErrInvalidToken } return fmt.Errorf("invite: %w", err) } func (s *Service) removeFile(path string) error { fi, err := os.Lstat(path) if errors.Is(err, fs.ErrNotExist) { return nil } if err != nil { return fmt.Errorf("invite: inspect %s: %w", path, err) } if !fi.Mode().IsRegular() { return fmt.Errorf("invite: %s is not a regular file", path) } if err := s.remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("invite: remove %s: %w", path, err) } return nil } func (s *Service) writeFile(path, content string) error { if err := s.removeFile(path); err != nil { return err } f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0o600) if err != nil { return fmt.Errorf("invite: create %s: %w", path, err) } fi, err := f.Stat() if err == nil && fi.Mode().Perm() != 0o600 { err = fmt.Errorf("mode %v, want 0600", fi.Mode().Perm()) } if err == nil { _, err = f.WriteString(content) } if closeErr := f.Close(); err == nil { err = closeErr } if err != nil { os.Remove(path) return fmt.Errorf("invite: write %s: %w", path, err) } return nil } func (s *Service) SetDefaultGroups(ctx context.Context, groups []string) error { if _, err := s.store.EnsureGroups(ctx, groups, "system"); err != nil { return fmt.Errorf("invite: create default groups: %w", err) } s.groupsMu.Lock() defer s.groupsMu.Unlock() s.cfg.DefaultGroups = slices.Clone(groups) return nil } func (s *Service) defaultGroups() []string { s.groupsMu.RLock() defer s.groupsMu.RUnlock() return s.cfg.DefaultGroups }