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}