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}