Raw
  1package storage
  2
  3import (
  4	"context"
  5	"crypto/subtle"
  6	"database/sql"
  7	"encoding/json"
  8	"errors"
  9	"fmt"
 10	"time"
 11)
 12
 13var (
 14	ErrInvalidToken   = errors.New("storage: invitation token is not valid")
 15	ErrBootstrapFirst = errors.New("storage: no user exists yet, so only the bootstrap invitation is redeemable")
 16	ErrUsernameTaken  = errors.New("storage: username is taken")
 17)
 18
 19type Grant int
 20
 21const (
 22	GrantOrdinary Grant = iota + 1
 23	GrantBootstrap
 24)
 25
 26type Redemption struct {
 27	Grant Grant
 28}
 29
 30type RedeemInput struct {
 31	TokenHash     []byte
 32	User          CreateUserInput
 33	DefaultGroups []string
 34	AdminGroup    string
 35}
 36
 37type Redeemed struct {
 38	User    User
 39	Grant   Grant
 40	Groups  []string
 41	Dropped []string
 42	Code    string
 43}
 44
 45type redemptionTarget struct {
 46	grant        Grant
 47	invitationID string
 48	validFor     time.Duration
 49}
 50
 51func (s *Store) CheckRedemption(ctx context.Context, tokenHash []byte) (Redemption, error) {
 52	const operation = "check redemption"
 53	if err := s.checkOpen(operation); err != nil {
 54		return Redemption{}, err
 55	}
 56	target, err := findRedemption(ctx, s.sql, operation, tokenHash, canonicalTime(s.now()))
 57	if err != nil {
 58		return Redemption{}, err
 59	}
 60	return Redemption{Grant: target.grant}, nil
 61}
 62
 63func (s *Store) RedeemInvitation(ctx context.Context, input RedeemInput) (Redeemed, error) {
 64	const operation = "redeem invitation"
 65	if err := s.checkOpen(operation); err != nil {
 66		return Redeemed{}, err
 67	}
 68	user, err := s.prepareUser(operation, input.User)
 69	if err != nil {
 70		return Redeemed{}, err
 71	}
 72	tx, err := s.sql.BeginTx(ctx, nil)
 73	if err != nil {
 74		return Redeemed{}, classify("begin "+operation, err)
 75	}
 76	defer tx.Rollback()
 77	target, err := findRedemption(ctx, tx, operation, input.TokenHash, user.CreatedAt)
 78	if err != nil {
 79		return Redeemed{}, err
 80	}
 81	groups := append([]string(nil), input.DefaultGroups...)
 82	if target.grant == GrantBootstrap {
 83		groups = appendUnique(groups, input.AdminGroup)
 84	}
 85	if err := requireFree(ctx, tx, operation, `SELECT 1 FROM users WHERE username = ?`, user.Username, ErrUsernameTaken); err != nil {
 86		return Redeemed{}, err
 87	}
 88	user.Disabled = target.grant == GrantOrdinary
 89	user, err = insertUser(ctx, tx, operation, user)
 90	if err != nil {
 91		return Redeemed{}, err
 92	}
 93	var code string
 94	if target.grant == GrantOrdinary {
 95		if code, err = insertPending(ctx, tx, operation, user.ID, user.CreatedAt, user.CreatedAt.Add(target.validFor)); err != nil {
 96			return Redeemed{}, err
 97		}
 98	}
 99	for _, name := range groups {
100		group, _, err := s.ensureGroup(ctx, tx, operation, name, "system")
101		if err != nil {
102			return Redeemed{}, err
103		}
104		if err := addMembership(ctx, tx, operation, user.ID, group.ID); err != nil {
105			return Redeemed{}, err
106		}
107	}
108	var dropped []string
109	if target.grant == GrantOrdinary {
110		if groups, dropped, err = grantInvitationGroups(ctx, tx, operation, target.invitationID, user.ID, input.AdminGroup, groups); err != nil {
111			return Redeemed{}, err
112		}
113	}
114	action, subject := "bootstrap.redeem", "bootstrap"
115	if target.grant == GrantOrdinary {
116		action, subject = "invitation.redeem", target.invitationID
117		result, err := tx.ExecContext(ctx, `UPDATE invitations SET used_by = ?, used_at = ? WHERE id = ? AND used_at IS NULL AND revoked_at IS NULL`, user.ID, timeToInt(user.CreatedAt), target.invitationID)
118		if err != nil {
119			return Redeemed{}, classify(operation, err)
120		}
121		if changed, err := result.RowsAffected(); err != nil {
122			return Redeemed{}, classify(operation+" rows affected", err)
123		} else if changed != 1 {
124			return Redeemed{}, fmt.Errorf("%s: %w", operation, ErrInvalidToken)
125		}
126	}
127	detail, err := json.Marshal(struct {
128		Username string   `json:"username"`
129		Groups   []string `json:"groups"`
130		Dropped  []string `json:"dropped,omitempty"`
131		Pending  bool     `json:"pending,omitempty"`
132	}{user.Username, groups, dropped, target.grant == GrantOrdinary})
133	if err != nil {
134		return Redeemed{}, fmt.Errorf("%s: %w", operation, err)
135	}
136	if _, err := appendAudit(ctx, tx, operation, AppendAuditInput{Actor: user.ID, Action: action, Subject: subject, Detail: string(detail)}, user.CreatedAt); err != nil {
137		return Redeemed{}, err
138	}
139	if err := tx.Commit(); err != nil {
140		return Redeemed{}, classify("commit "+operation, err)
141	}
142	return Redeemed{User: user, Grant: target.grant, Groups: groups, Dropped: dropped, Code: code}, nil
143}
144
145func findRedemption(ctx context.Context, q execer, operation string, tokenHash []byte, now time.Time) (redemptionTarget, error) {
146	invalidToken := fmt.Errorf("%s: %w", operation, ErrInvalidToken)
147	if len(tokenHash) != bootstrapHashLength {
148		return redemptionTarget{}, invalidToken
149	}
150	var id string
151	var invCreated, invExpires int64
152	err := q.QueryRowContext(ctx, `
153SELECT id, created_at, expires_at FROM invitations
154WHERE token_hash = ? AND used_at IS NULL AND revoked_at IS NULL AND expires_at > ?`,
155		tokenHash, timeToInt(now)).Scan(&id, &invCreated, &invExpires)
156	if err == nil {
157		users, err := countUsers(ctx, q, operation)
158		if err != nil {
159			return redemptionTarget{}, err
160		}
161		if users == 0 {
162			return redemptionTarget{}, fmt.Errorf("%s: %w: %w", operation, ErrInvalidToken, ErrBootstrapFirst)
163		}
164		return redemptionTarget{grant: GrantOrdinary, invitationID: id, validFor: intToTime(invExpires).Sub(intToTime(invCreated))}, nil
165	}
166	if !errors.Is(err, sql.ErrNoRows) {
167		return redemptionTarget{}, classify(operation, err)
168	}
169	var stored []byte
170	var expires sql.NullInt64
171	if err := q.QueryRowContext(ctx, `SELECT bootstrap_token_hash, bootstrap_expires_at FROM settings WHERE id = 1`).Scan(&stored, &expires); err != nil {
172		return redemptionTarget{}, classify(operation, err)
173	}
174	if len(stored) != bootstrapHashLength || !expires.Valid || subtle.ConstantTimeCompare(stored, tokenHash) != 1 {
175		return redemptionTarget{}, invalidToken
176	}
177	if !intToTime(expires.Int64).After(now) {
178		return redemptionTarget{}, invalidToken
179	}
180	users, err := countUsers(ctx, q, operation)
181	if err != nil {
182		return redemptionTarget{}, err
183	}
184	if users != 0 {
185		return redemptionTarget{}, invalidToken
186	}
187	return redemptionTarget{grant: GrantBootstrap}, nil
188}
189
190func requireFree(ctx context.Context, tx *sql.Tx, operation, query, value string, taken error) error {
191	var one int
192	err := tx.QueryRowContext(ctx, query, value).Scan(&one)
193	if errors.Is(err, sql.ErrNoRows) {
194		return nil
195	}
196	if err != nil {
197		return classify(operation, err)
198	}
199	return fmt.Errorf("%s: %w", operation, taken)
200}
201
202func appendUnique(names []string, name string) []string {
203	for _, existing := range names {
204		if existing == name {
205			return names
206		}
207	}
208	return append(names, name)
209}