generated from matoszz/mitbgo-template
-
Notifications
You must be signed in to change notification settings - Fork 7
/
invite.go
263 lines (212 loc) · 7.95 KB
/
invite.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
package handlers
import (
"context"
"errors"
"net/http"
"time"
"entgo.io/ent/dialect/sql"
echo "github.com/datumforge/echox"
"github.com/oklog/ulid/v2"
"github.com/datumforge/datum/internal/ent/enums"
"github.com/datumforge/datum/internal/ent/generated"
"github.com/datumforge/datum/internal/ent/privacy/token"
"github.com/datumforge/datum/pkg/auth"
"github.com/datumforge/datum/pkg/middleware/transaction"
"github.com/datumforge/datum/pkg/rout"
"github.com/datumforge/datum/pkg/tokens"
)
// InviteRequest holds the fields that should be included on a request to the `/invite` endpoint
type InviteRequest struct {
Token string `query:"token"`
}
// InviteReply holds the fields that are sent on a response to an accepted invitation
// Note: there is no InviteRequest as this is handled via our graph interfaces
type InviteReply struct {
rout.Reply
ID string `json:"user_id"`
Email string `json:"email"`
Message string `json:"message"`
JoinedOrgID string `json:"joined_org_id"`
Role string `json:"role"`
}
// Invite holds the Token, InviteToken references, and the additional user input to //
// complete acceptance of the invitation
type Invite struct {
Token string
UserID ulid.ULID
Email string
DestOrgID ulid.ULID
Role enums.Role
InviteToken
}
// InviteToken holds data specific to a future user of the system for invite logic
type InviteToken struct {
Expires sql.NullString
Token sql.NullString
Secret []byte
}
// OrganizationInviteAccept is responsible for handling the invitation of a user to an organization.
// It receives a request with the user's invitation details, validates the request,
// and creates organization membership for the user
// On success, it returns a response with the organization information
func (h *Handler) OrganizationInviteAccept(ctx echo.Context) error {
// parse the token out of the context
req := new(InviteRequest)
if err := ctx.Bind(req); err != nil {
return ctx.JSON(http.StatusBadRequest, rout.ErrorResponse(err))
}
reqCtx := ctx.Request().Context()
// get the authenticated user from the context
userID, err := auth.GetUserIDFromContext(reqCtx)
if err != nil {
h.Logger.Errorw("unable to get user id from context", "error", err)
return ctx.JSON(http.StatusBadRequest, rout.ErrorResponse(err))
}
inv := &Invite{
Token: req.Token,
}
// ensure the user that is logged in, matches the invited user
if err := inv.validateInviteRequest(); err != nil {
return ctx.JSON(http.StatusBadRequest, rout.ErrorResponse(err))
}
// set the initial context based on the token
ctxWithToken := token.NewContextWithOrgInviteToken(reqCtx, inv.Token)
// fetch the recipient and org owner based on token
invitedUser, err := h.getUserByInviteToken(ctxWithToken, inv.Token)
if err != nil {
if generated.IsNotFound(err) {
return ctx.JSON(http.StatusBadRequest, rout.ErrorResponse(err))
}
h.Logger.Errorf("error retrieving invite token", "error", err)
return ctx.JSON(http.StatusInternalServerError, nil)
}
// add email to the invite
inv.Email = invitedUser.Recipient
// get user details for logged in user
user, err := h.getUserDetailsByID(reqCtx, userID)
if err != nil {
h.Logger.Errorw("unable to get user for request", "error", err)
return ctx.JSON(http.StatusUnauthorized, rout.ErrorResponse(err))
}
// ensure the user that is logged in, matches the invited user
if err := inv.validateUser(user.Email); err != nil {
return ctx.JSON(http.StatusBadRequest, rout.ErrorResponse(err))
}
// string to ulid so we can match the token input
oid, err := ulid.Parse(invitedUser.OwnerID)
if err != nil {
return ctx.JSON(http.StatusBadRequest, rout.ErrorResponse(err))
}
// string to ulid so we can match the token input
uid, err := ulid.Parse(userID)
if err != nil {
return ctx.JSON(http.StatusBadRequest, rout.ErrorResponse(err))
}
// construct the invite details but set email to the original recipient, and the joining organization ID as the current owner of the invitation
invite := &Invite{
Email: invitedUser.Recipient,
UserID: uid,
DestOrgID: oid,
Role: invitedUser.Role,
}
// set tokens for request
if err := invite.setOrgInviteTokens(invitedUser, inv.Token); err != nil {
h.Logger.Errorw("unable to set invite token for request", "error", err)
return ctx.JSON(http.StatusBadRequest, rout.ErrorResponse(err))
}
// reconstruct the token based on recipient & owning organization so we can compare it to the one were receiving
t := &tokens.OrgInviteToken{
Email: invitedUser.Recipient,
OrgID: oid,
}
// check and ensure the token has not expired
if t.ExpiresAt, err = invite.GetInviteExpires(); err != nil {
h.Logger.Errorw("unable to parse expiration", "error", err)
return ctx.JSON(http.StatusInternalServerError, tokens.ErrTokenExpired)
}
// Verify the token is valid with the stored secret
if err = t.Verify(invite.GetInviteToken(), invite.Secret); err != nil {
if errors.Is(err, tokens.ErrTokenExpired) {
if err := updateInviteStatusExpired(ctxWithToken, invitedUser); err != nil {
return err
}
out := &InviteReply{
Message: "invite token is expired, you will need to re-request an invite",
}
return ctx.JSON(http.StatusBadRequest, out)
}
return ctx.JSON(http.StatusBadRequest, rout.ErrorResponse(err))
}
if err := updateInviteStatusAccepted(ctxWithToken, invitedUser); err != nil {
return ctx.JSON(http.StatusBadRequest, rout.ErrorResponse(err))
}
// reply with the relevant details
out := &InviteReply{
Reply: rout.Reply{Success: true},
ID: userID,
Email: invitedUser.Recipient,
JoinedOrgID: invitedUser.OwnerID,
Role: string(invitedUser.Role),
Message: "Welcome to your new organization!",
}
return ctx.JSON(http.StatusCreated, out)
}
// validateInviteRequest validates the required fields are set in the user request
func (i *Invite) validateInviteRequest() error {
// ensure the token is set
if i.Token == "" {
return rout.NewMissingRequiredFieldError("token")
}
return nil
}
func (i *Invite) validateUser(email string) error {
// ensure the logged in user is the same as the invite
if i.Email != email {
return ErrUnableToVerifyEmail
}
return nil
}
// GetInviteToken returns the invitation token if its valid
func (i *Invite) GetInviteToken() string {
if i.InviteToken.Token.Valid {
return i.InviteToken.Token.String
}
return ""
}
// GetInviteExpires returns the expiration time of invite token
func (i *Invite) GetInviteExpires() (time.Time, error) {
if i.InviteToken.Expires.Valid {
return time.Parse(time.RFC3339Nano, i.InviteToken.Expires.String)
}
return time.Time{}, nil
}
// setOrgInviteTokens ets the fields of the `Invite` struct to verify the email
// invitation. It takes in an `Invite` object and an invitation token as parameters. If
// the invitation token matches the token stored in the `Invite` object, it sets the
// `Token`, `Secret`, and `Expires` fields of the `InviteToken` struct. This allows the
// token to be verified later when the user accepts the invitation
func (i *Invite) setOrgInviteTokens(inv *generated.Invite, invToken string) error {
if inv.Token == invToken {
i.InviteToken.Token = sql.NullString{String: inv.Token, Valid: true}
i.InviteToken.Secret = *inv.Secret
i.InviteToken.Expires = sql.NullString{String: inv.Expires.Format(time.RFC3339Nano), Valid: true}
return nil
}
return ErrNotFound
}
// updateInviteStatusAccepted updates the status of an invite to "Accepted"
func updateInviteStatusAccepted(ctx context.Context, i *generated.Invite) error {
_, err := transaction.FromContext(ctx).Invite.UpdateOneID(i.ID).SetStatus(enums.InvitationAccepted).Save(ctx)
if err != nil {
return err
}
return nil
}
// updateInviteStatusAccepted updates the status of an invite to "Expired"
func updateInviteStatusExpired(ctx context.Context, i *generated.Invite) error {
_, err := transaction.FromContext(ctx).Invite.UpdateOneID(i.ID).SetStatus(enums.InvitationExpired).Save(ctx)
if err != nil {
return err
}
return nil
}