• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

supabase / gotrue / 8135412125

04 Mar 2024 03:34AM UTC coverage: 65.142% (+0.1%) from 65.009%
8135412125

push

github

web-flow
fix: refactor request params to use generics (#1464)

## What kind of change does this PR introduce?
* Introduce a new method `retrieveRequestParams` which makes use of
generics to parse a request
* This will help to simplify parsing a request from:
```go

params := RequestParams{}
body, err := getBodyBytes(r)
if err != nil {
  return nil, badRequestError("Could not read body").WithInternalError(err)
}

if err := json.Unmarshal(body, &params); err != nil {
  return nil, badRequestError("Could not decode request params: %v", err)
}
```
to 
```go
params := &Request{}
err := retrieveRequestParams(req, params)
```

## TODO
- [x] Add type constraint instead of using `any`

48 of 69 new or added lines in 19 files covered. (69.57%)

19 existing lines in 14 files now uncovered.

7806 of 11983 relevant lines covered (65.14%)

59.29 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

70.59
/internal/api/mfa.go
1
package api
2

3
import (
4
        "bytes"
5
        "fmt"
6
        "net/http"
7
        "net/url"
8

9
        "github.com/aaronarduino/goqrsvg"
10
        svg "github.com/ajstarks/svgo"
11
        "github.com/boombuler/barcode/qr"
12
        "github.com/gofrs/uuid"
13
        "github.com/pquerna/otp/totp"
14
        "github.com/supabase/auth/internal/hooks"
15
        "github.com/supabase/auth/internal/metering"
16
        "github.com/supabase/auth/internal/models"
17
        "github.com/supabase/auth/internal/storage"
18
        "github.com/supabase/auth/internal/utilities"
19
)
20

21
const DefaultQRSize = 3
22

23
type EnrollFactorParams struct {
24
        FriendlyName string `json:"friendly_name"`
25
        FactorType   string `json:"factor_type"`
26
        Issuer       string `json:"issuer"`
27
}
28

29
type TOTPObject struct {
30
        QRCode string `json:"qr_code"`
31
        Secret string `json:"secret"`
32
        URI    string `json:"uri"`
33
}
34

35
type EnrollFactorResponse struct {
36
        ID           uuid.UUID  `json:"id"`
37
        Type         string     `json:"type"`
38
        FriendlyName string     `json:"friendly_name"`
39
        TOTP         TOTPObject `json:"totp,omitempty"`
40
}
41

42
type VerifyFactorParams struct {
43
        ChallengeID uuid.UUID `json:"challenge_id"`
44
        Code        string    `json:"code"`
45
}
46

47
type ChallengeFactorResponse struct {
48
        ID        uuid.UUID `json:"id"`
49
        ExpiresAt int64     `json:"expires_at"`
50
}
51

52
type UnenrollFactorResponse struct {
53
        ID uuid.UUID `json:"id"`
54
}
55

56
const (
57
        InvalidFactorOwnerErrorMessage = "Factor does not belong to user"
58
        QRCodeGenerationErrorMessage   = "Error generating QR Code"
59
)
60

61
func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error {
13✔
62
        ctx := r.Context()
13✔
63
        user := getUser(ctx)
13✔
64
        session := getSession(ctx)
13✔
65
        config := a.config
13✔
66

13✔
67
        params := &EnrollFactorParams{}
13✔
68
        if err := retrieveRequestParams(r, params); err != nil {
13✔
NEW
69
                return err
×
UNCOV
70
        }
×
71
        issuer := ""
13✔
72

13✔
73
        if params.FactorType != models.TOTP {
14✔
74
                return badRequestError("factor_type needs to be totp")
1✔
75
        }
1✔
76

77
        if params.Issuer == "" {
13✔
78
                u, err := url.ParseRequestURI(config.SiteURL)
1✔
79
                if err != nil {
1✔
80
                        return internalServerError("site url is improperly formatted")
×
81
                }
×
82
                issuer = u.Host
1✔
83
        } else {
11✔
84
                issuer = params.Issuer
11✔
85
        }
11✔
86

87
        // Read from DB for certainty
88
        factors, err := models.FindFactorsByUser(a.db, user)
12✔
89
        if err != nil {
12✔
90
                return internalServerError("error validating number of factors in system").WithInternalError(err)
×
91
        }
×
92

93
        if len(factors) >= int(config.MFA.MaxEnrolledFactors) {
12✔
94
                return forbiddenError("Enrolled factors exceed allowed limit, unenroll to continue")
×
95
        }
×
96

97
        numVerifiedFactors := 0
12✔
98
        for _, factor := range factors {
23✔
99
                if factor.IsVerified() {
11✔
100
                        numVerifiedFactors += 1
×
101
                }
×
102
        }
103

104
        if numVerifiedFactors >= config.MFA.MaxVerifiedFactors {
12✔
105
                return forbiddenError("Maximum number of enrolled factors reached, unenroll to continue")
×
106
        }
×
107

108
        if numVerifiedFactors > 0 && !session.IsAAL2() {
12✔
109
                return forbiddenError("AAL2 required to enroll a new factor")
×
110
        }
×
111

112
        key, err := totp.Generate(totp.GenerateOpts{
12✔
113
                Issuer:      issuer,
12✔
114
                AccountName: user.GetEmail(),
12✔
115
        })
12✔
116
        if err != nil {
12✔
117
                return internalServerError(QRCodeGenerationErrorMessage).WithInternalError(err)
×
118
        }
×
119
        var buf bytes.Buffer
12✔
120
        svgData := svg.New(&buf)
12✔
121
        qrCode, _ := qr.Encode(key.String(), qr.H, qr.Auto)
12✔
122
        qs := goqrsvg.NewQrSVG(qrCode, DefaultQRSize)
12✔
123
        qs.StartQrSVG(svgData)
12✔
124
        if err = qs.WriteQrSVG(svgData); err != nil {
12✔
125
                return internalServerError(QRCodeGenerationErrorMessage).WithInternalError(err)
×
126
        }
×
127
        svgData.End()
12✔
128

12✔
129
        factor := models.NewFactor(user, params.FriendlyName, params.FactorType, models.FactorStateUnverified, key.Secret())
12✔
130

12✔
131
        err = a.db.Transaction(func(tx *storage.Connection) error {
24✔
132
                if terr := tx.Create(factor); terr != nil {
13✔
133
                        pgErr := utilities.NewPostgresError(terr)
1✔
134
                        if pgErr.IsUniqueConstraintViolated() {
2✔
135
                                return badRequestError(fmt.Sprintf("a factor with the friendly name %q for this user likely already exists", factor.FriendlyName))
1✔
136
                        }
1✔
137
                        return terr
×
138

139
                }
140
                if terr := models.NewAuditLogEntry(r, tx, user, models.EnrollFactorAction, r.RemoteAddr, map[string]interface{}{
11✔
141
                        "factor_id": factor.ID,
11✔
142
                }); terr != nil {
11✔
143
                        return terr
×
144
                }
×
145
                return nil
11✔
146
        })
147
        if err != nil {
13✔
148
                return err
1✔
149
        }
1✔
150

151
        return sendJSON(w, http.StatusOK, &EnrollFactorResponse{
11✔
152
                ID:           factor.ID,
11✔
153
                Type:         models.TOTP,
11✔
154
                FriendlyName: factor.FriendlyName,
11✔
155
                TOTP: TOTPObject{
11✔
156
                        // See: https://css-tricks.com/probably-dont-base64-svg/
11✔
157
                        QRCode: buf.String(),
11✔
158
                        Secret: factor.Secret,
11✔
159
                        URI:    key.URL(),
11✔
160
                },
11✔
161
        })
11✔
162
}
163

164
func (a *API) ChallengeFactor(w http.ResponseWriter, r *http.Request) error {
8✔
165
        ctx := r.Context()
8✔
166
        config := a.config
8✔
167

8✔
168
        user := getUser(ctx)
8✔
169
        factor := getFactor(ctx)
8✔
170
        ipAddress := utilities.GetIPAddress(r)
8✔
171
        challenge := models.NewChallenge(factor, ipAddress)
8✔
172

8✔
173
        err := a.db.Transaction(func(tx *storage.Connection) error {
16✔
174
                if terr := tx.Create(challenge); terr != nil {
8✔
175
                        return terr
×
176
                }
×
177
                if terr := models.NewAuditLogEntry(r, tx, user, models.CreateChallengeAction, r.RemoteAddr, map[string]interface{}{
8✔
178
                        "factor_id":     factor.ID,
8✔
179
                        "factor_status": factor.Status,
8✔
180
                }); terr != nil {
8✔
181
                        return terr
×
182
                }
×
183
                return nil
8✔
184
        })
185
        if err != nil {
8✔
186
                return err
×
187
        }
×
188

189
        return sendJSON(w, http.StatusOK, &ChallengeFactorResponse{
8✔
190
                ID:        challenge.ID,
8✔
191
                ExpiresAt: challenge.GetExpiryTime(config.MFA.ChallengeExpiryDuration).Unix(),
8✔
192
        })
8✔
193
}
194

195
func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error {
10✔
196
        var err error
10✔
197
        ctx := r.Context()
10✔
198
        user := getUser(ctx)
10✔
199
        factor := getFactor(ctx)
10✔
200
        config := a.config
10✔
201

10✔
202
        params := &VerifyFactorParams{}
10✔
203
        if err := retrieveRequestParams(r, params); err != nil {
10✔
NEW
204
                return err
×
UNCOV
205
        }
×
206
        currentIP := utilities.GetIPAddress(r)
10✔
207

10✔
208
        if !factor.IsOwnedBy(user) {
10✔
209
                return internalServerError(InvalidFactorOwnerErrorMessage)
×
210
        }
×
211

212
        challenge, err := models.FindChallengeByChallengeID(a.db, params.ChallengeID)
10✔
213
        if err != nil {
10✔
214
                if models.IsNotFoundError(err) {
×
215
                        return notFoundError(err.Error())
×
216
                }
×
217
                return internalServerError("Database error finding Challenge").WithInternalError(err)
×
218
        }
219

220
        if challenge.VerifiedAt != nil || challenge.IPAddress != currentIP {
10✔
221
                return badRequestError("Challenge and verify IP addresses mismatch")
×
222
        }
×
223

224
        if challenge.HasExpired(config.MFA.ChallengeExpiryDuration) {
11✔
225
                err := a.db.Transaction(func(tx *storage.Connection) error {
2✔
226
                        if terr := tx.Destroy(challenge); terr != nil {
1✔
227
                                return internalServerError("Database error deleting challenge").WithInternalError(terr)
×
228
                        }
×
229

230
                        return nil
1✔
231
                })
232
                if err != nil {
1✔
233
                        return err
×
234
                }
×
235
                return badRequestError("%v has expired, verify against another challenge or create a new challenge.", challenge.ID)
1✔
236
        }
237

238
        valid := totp.Validate(params.Code, factor.Secret)
9✔
239

9✔
240
        if config.Hook.MFAVerificationAttempt.Enabled {
13✔
241
                input := hooks.MFAVerificationAttemptInput{
4✔
242
                        UserID:   user.ID,
4✔
243
                        FactorID: factor.ID,
4✔
244
                        Valid:    valid,
4✔
245
                }
4✔
246

4✔
247
                output := hooks.MFAVerificationAttemptOutput{}
4✔
248

4✔
249
                err := a.invokeHook(ctx, nil, &input, &output)
4✔
250
                if err != nil {
6✔
251
                        return err
2✔
252
                }
2✔
253

254
                if output.Decision == hooks.HookRejection {
3✔
255
                        if err := models.Logout(a.db, user.ID); err != nil {
1✔
256
                                return err
×
257
                        }
×
258

259
                        if output.Message == "" {
1✔
260
                                output.Message = hooks.DefaultMFAHookRejectionMessage
×
261
                        }
×
262

263
                        return forbiddenError(output.Message)
1✔
264
                }
265
        }
266
        if !valid {
7✔
267
                return badRequestError("Invalid TOTP code entered")
1✔
268
        }
1✔
269

270
        var token *AccessTokenResponse
5✔
271
        err = a.db.Transaction(func(tx *storage.Connection) error {
10✔
272
                var terr error
5✔
273
                if terr = models.NewAuditLogEntry(r, tx, user, models.VerifyFactorAction, r.RemoteAddr, map[string]interface{}{
5✔
274
                        "factor_id":    factor.ID,
5✔
275
                        "challenge_id": challenge.ID,
5✔
276
                }); terr != nil {
5✔
277
                        return terr
×
278
                }
×
279
                if terr = challenge.Verify(tx); terr != nil {
5✔
280
                        return terr
×
281
                }
×
282
                if !factor.IsVerified() {
10✔
283
                        if terr = factor.UpdateStatus(tx, models.FactorStateVerified); terr != nil {
5✔
284
                                return terr
×
285
                        }
×
286
                }
287
                user, terr = models.FindUserByID(tx, user.ID)
5✔
288
                if terr != nil {
5✔
289
                        return terr
×
290
                }
×
291
                token, terr = a.updateMFASessionAndClaims(r, tx, user, models.TOTPSignIn, models.GrantParams{
5✔
292
                        FactorID: &factor.ID,
5✔
293
                })
5✔
294
                if terr != nil {
5✔
295
                        return terr
×
296
                }
×
297
                if terr = a.setCookieTokens(config, token, false, w); terr != nil {
5✔
298
                        return internalServerError("Failed to set JWT cookie. %s", terr)
×
299
                }
×
300
                if terr = models.InvalidateSessionsWithAALLessThan(tx, user.ID, models.AAL2.String()); terr != nil {
5✔
301
                        return internalServerError("Failed to update sessions. %s", terr)
×
302
                }
×
303
                if terr = models.DeleteUnverifiedFactors(tx, user); terr != nil {
5✔
304
                        return internalServerError("Error removing unverified factors. %s", terr)
×
305
                }
×
306
                return nil
5✔
307
        })
308
        if err != nil {
5✔
309
                return err
×
310
        }
×
311
        metering.RecordLogin(string(models.MFACodeLoginAction), user.ID)
5✔
312

5✔
313
        return sendJSON(w, http.StatusOK, token)
5✔
314

315
}
316

317
func (a *API) UnenrollFactor(w http.ResponseWriter, r *http.Request) error {
3✔
318
        var err error
3✔
319
        ctx := r.Context()
3✔
320
        user := getUser(ctx)
3✔
321
        factor := getFactor(ctx)
3✔
322
        session := getSession(ctx)
3✔
323
        if factor == nil || session == nil || user == nil {
3✔
324
                return internalServerError("A valid session and factor are required to unenroll a factor")
×
325
        }
×
326

327
        if factor.IsVerified() && !session.IsAAL2() {
4✔
328
                return badRequestError("AAL2 required to unenroll verified factor")
1✔
329
        }
1✔
330
        if !factor.IsOwnedBy(user) {
2✔
331
                return internalServerError(InvalidFactorOwnerErrorMessage)
×
332
        }
×
333

334
        err = a.db.Transaction(func(tx *storage.Connection) error {
4✔
335
                var terr error
2✔
336
                if terr := tx.Destroy(factor); terr != nil {
2✔
337
                        return terr
×
338
                }
×
339
                if terr = models.NewAuditLogEntry(r, tx, user, models.UnenrollFactorAction, r.RemoteAddr, map[string]interface{}{
2✔
340
                        "factor_id":     factor.ID,
2✔
341
                        "factor_status": factor.Status,
2✔
342
                        "session_id":    session.ID,
2✔
343
                }); terr != nil {
2✔
344
                        return terr
×
345
                }
×
346
                if terr = factor.DowngradeSessionsToAAL1(tx); terr != nil {
2✔
347
                        return terr
×
348
                }
×
349
                return nil
2✔
350
        })
351
        if err != nil {
2✔
352
                return err
×
353
        }
×
354

355
        return sendJSON(w, http.StatusOK, &UnenrollFactorResponse{
2✔
356
                ID: factor.ID,
2✔
357
        })
2✔
358
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc