/
invites.go
300 lines (248 loc) · 7.65 KB
/
invites.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
package sqlite
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"fmt"
"github.com/friendsofgo/errors"
"github.com/mattn/go-sqlite3"
"github.com/volatiletech/sqlboiler/v4/boil"
"github.com/volatiletech/sqlboiler/v4/queries/qm"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb/sqlite/models"
refs "go.mindeco.de/ssb-refs"
)
// compiler assertion to ensure the struct fullfills the interface
var _ roomdb.InvitesService = (*Invites)(nil)
// Invites implements the roomdb.InviteService.
// Tokens are stored as sha256 hashes on disk to protect against attackers gaining database read-access.
type Invites struct {
db *sql.DB
members Members
}
// Create creates a new invite for a new member. It returns the token or an error.
// createdBy is user ID of the admin or moderator who created it.
// aliasSuggestion is optional (empty string is fine) but can be used to disambiguate open invites. (See https://github.com/ssb-ngi-pointer/rooms2/issues/21)
// The returned token is base64 URL encoded and has inviteTokenLength when decoded.
func (i Invites) Create(ctx context.Context, createdBy int64) (string, error) {
var newInvite = models.Invite{
CreatedBy: createdBy,
}
tokenBytes := make([]byte, inviteTokenLength)
err := transact(i.db, func(tx *sql.Tx) error {
if createdBy == -1 {
config, err := models.FindConfig(ctx, tx, configRowID)
if err != nil {
return err
}
if config.PrivacyMode != roomdb.ModeOpen {
return fmt.Errorf("roomdb: privacy mode not set to open but %s", config.PrivacyMode.String())
}
m, err := models.Members(qm.Where("role = ?", roomdb.RoleAdmin)).One(ctx, tx)
if err != nil {
// we could insert something like a system user but should probably hit it from the members list then
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("roomdb: no admin user available to associate invite to")
}
return err
}
newInvite.CreatedBy = m.ID
}
inserted := false
trying:
for tries := 100; tries > 0; tries-- {
// generate an invite code
rand.Read(tokenBytes)
// hash the binary of the token for storage
h := sha256.New()
h.Write(tokenBytes)
newInvite.HashedToken = fmt.Sprintf("%x", h.Sum(nil))
// insert the new invite
err := newInvite.Insert(ctx, tx, boil.Infer())
if err != nil {
var sqlErr sqlite3.Error
if errors.As(err, &sqlErr) && sqlErr.ExtendedCode == sqlite3.ErrConstraintUnique {
// generated an existing token, retry
continue trying
}
return err
}
inserted = true
break // no error means it worked!
}
if !inserted {
return errors.New("roomdb: failed to generate an invite token in a reasonable amount of time")
}
return nil
})
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(tokenBytes), nil
}
// Consume checks if the passed token is still valid. If it is it adds newMember to the members of the room and invalidates the token.
// If the token isn't valid, it returns an error.
// Tokens need to be base64 URL encoded and when decoded be of inviteTokenLength.
func (i Invites) Consume(ctx context.Context, token string, newMember refs.FeedRef) (roomdb.Invite, error) {
var inv roomdb.Invite
hashedToken, err := getHashedToken(token)
if err != nil {
return inv, err
}
err = transact(i.db, func(tx *sql.Tx) error {
entry, err := models.Invites(
qm.Where("active = true AND hashed_token = ?", hashedToken),
qm.Load("CreatedByMember"),
).One(ctx, tx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return roomdb.ErrNotFound
}
return err
}
_, err = i.members.add(ctx, tx, newMember, roomdb.RoleMember)
if err != nil {
return err
}
// invalidate the invite for consumption
entry.Active = false
_, err = entry.Update(ctx, tx, boil.Whitelist("active"))
if err != nil {
return err
}
inv.ID = entry.ID
inv.CreatedAt = entry.CreatedAt
inv.CreatedBy.ID = entry.R.CreatedByMember.ID
inv.CreatedBy.Role = roomdb.Role(entry.R.CreatedByMember.Role)
return nil
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return inv, roomdb.ErrNotFound
}
return inv, err
}
return inv, nil
}
// since invites are marked as inavalid so that the code can't be generated twice,
// they need to be deleted periodically.
func deleteConsumedInvites(tx boil.ContextExecutor) error {
_, err := models.Invites(qm.Where("active = false")).DeleteAll(context.Background(), tx)
if err != nil {
return fmt.Errorf("roomdb: failed to delete used invites: %w", err)
}
return nil
}
func (i Invites) GetByToken(ctx context.Context, token string) (roomdb.Invite, error) {
var inv roomdb.Invite
ht, err := getHashedToken(token)
if err != nil {
return inv, err
}
entry, err := models.Invites(
qm.Where("active = true AND hashed_token = ?", ht),
qm.Load("CreatedByMember"),
).One(ctx, i.db)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return inv, roomdb.ErrNotFound
}
return inv, err
}
inv.ID = entry.ID
inv.CreatedAt = entry.CreatedAt
inv.CreatedBy.ID = entry.R.CreatedByMember.ID
inv.CreatedBy.Role = roomdb.Role(entry.R.CreatedByMember.Role)
return inv, nil
}
func (i Invites) GetByID(ctx context.Context, id int64) (roomdb.Invite, error) {
var inv roomdb.Invite
entry, err := models.Invites(
qm.Where("active = true AND id = ?", id),
qm.Load("CreatedByMember"),
).One(ctx, i.db)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return inv, roomdb.ErrNotFound
}
return inv, err
}
inv.ID = entry.ID
inv.CreatedAt = entry.CreatedAt
inv.CreatedBy.ID = entry.R.CreatedByMember.ID
inv.CreatedBy.Role = roomdb.Role(entry.R.CreatedByMember.Role)
inv.CreatedBy.PubKey = entry.R.CreatedByMember.PubKey.FeedRef
inv.CreatedBy.Aliases = i.members.getAliases(entry.R.CreatedByMember)
return inv, nil
}
// List returns a list of all the valid invites
func (i Invites) List(ctx context.Context) ([]roomdb.Invite, error) {
var invs []roomdb.Invite
err := transact(i.db, func(tx *sql.Tx) error {
entries, err := models.Invites(
qm.Where("active = true"),
qm.Load("CreatedByMember"),
).All(ctx, tx)
if err != nil {
return err
}
invs = make([]roomdb.Invite, len(entries))
for idx, e := range entries {
var inv roomdb.Invite
inv.ID = e.ID
inv.CreatedAt = e.CreatedAt
inv.CreatedBy.ID = e.R.CreatedByMember.ID
inv.CreatedBy.PubKey = e.R.CreatedByMember.PubKey.FeedRef
inv.CreatedBy.Aliases = i.members.getAliases(e.R.CreatedByMember)
invs[idx] = inv
}
return nil
})
if err != nil {
return nil, err
}
return invs, nil
}
func (i Invites) Count(ctx context.Context) (uint, error) {
count, err := models.Invites().Count(ctx, i.db)
if err != nil {
return 0, err
}
return uint(count), nil
}
// Revoke removes a active invite and invalidates it for future use.
func (i Invites) Revoke(ctx context.Context, id int64) error {
return transact(i.db, func(tx *sql.Tx) error {
entry, err := models.Invites(
qm.Where("active = true AND id = ?", id),
).One(ctx, tx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return roomdb.ErrNotFound
}
return err
}
entry.Active = false
_, err = entry.Update(ctx, tx, boil.Whitelist("active"))
if err != nil {
return err
}
return nil
})
}
const inviteTokenLength = 50
func getHashedToken(b64tok string) (string, error) {
tokenBytes, err := base64.URLEncoding.DecodeString(b64tok)
if err != nil {
return "", err
}
if n := len(tokenBytes); n != inviteTokenLength {
return "", fmt.Errorf("roomdb: invalid invite token length (only got %d bytes)", n)
}
// hash the binary of the passed token
h := sha256.New()
h.Write(tokenBytes)
return fmt.Sprintf("%x", h.Sum(nil)), nil
}