Skip to content

Commit 03e2908

Browse files
committed
Add WebAuthn login support
1 parent 38ee035 commit 03e2908

5 files changed

Lines changed: 213 additions & 30 deletions

File tree

controllers/accounts.go

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ type AccountsController struct {
3535
type PasswordFinalizeResponse struct {
3636
// Authentication token (only present if resetting/changing password)
3737
AuthToken *string `json:"authToken"`
38-
// Indicates to the client whether 2FA verification will be required before password setup is complete
39-
RequiresTwoFA bool `json:"requiresTwoFA"`
38+
// Two-factor authentication options available for this account
39+
TwoFAOptions *services.TwoFAOptions `json:"twoFAOptions"`
4040
// Indicates to the client whether email verification will be required before password setup is complete
4141
RequiresEmailVerification bool `json:"requiresEmailVerification"`
4242
// Indicates to the client that sessions were invalidated (only applicable when changing password)
@@ -507,13 +507,23 @@ func (ac *AccountsController) SetupPasswordFinalize(w http.ResponseWriter, r *ht
507507
}
508508
}
509509

510-
render.Status(r, http.StatusOK)
511-
render.JSON(w, r, &PasswordFinalizeResponse{
510+
response := &PasswordFinalizeResponse{
512511
AuthToken: authToken,
513-
RequiresTwoFA: registrationState.IsTwoFAEnabled(),
514512
RequiresEmailVerification: verification.Intent == datastore.RegistrationIntent,
515513
SessionsInvalidated: sessionsInvalidated,
516-
})
514+
}
515+
516+
if registrationState.IsTwoFAEnabled() {
517+
twoFAOptions, err := ac.twoFAService.PrepareChallenge(registrationState)
518+
if err != nil {
519+
util.RenderErrorResponse(w, r, http.StatusInternalServerError, err)
520+
return
521+
}
522+
response.TwoFAOptions = twoFAOptions
523+
}
524+
525+
render.Status(r, http.StatusOK)
526+
render.JSON(w, r, response)
517527
}
518528

519529
// @Summary Finalize password setup with 2FA
@@ -556,7 +566,10 @@ func (ac *AccountsController) SetupPasswordFinalize2FA(w http.ResponseWriter, r
556566
}
557567

558568
if err := ac.twoFAService.ProcessChallenge(registrationState, &requestData); err != nil {
559-
if errors.Is(err, util.ErrBadTOTPCode) || errors.Is(err, util.ErrBadRecoveryKey) {
569+
if errors.Is(err, util.ErrBadTOTPCode) ||
570+
errors.Is(err, util.ErrBadRecoveryKey) ||
571+
errors.Is(err, util.ErrTOTPCodeAlreadyUsed) ||
572+
errors.Is(err, util.ErrBadWebAuthnResponse) {
560573
util.RenderErrorResponse(w, r, http.StatusUnauthorized, err)
561574
return
562575
}

controllers/accounts_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ func (suite *AccountsTestSuite) TestRegistration() {
339339
util.DecodeJSONTestResponse(suite.T(), resp.Body, &parsedFinalizeResp)
340340
suite.Nil(parsedFinalizeResp.AuthToken) // No auth token until email is verified
341341
suite.True(parsedFinalizeResp.RequiresEmailVerification)
342-
suite.False(parsedFinalizeResp.RequiresTwoFA)
342+
suite.Nil(parsedFinalizeResp.TwoFAOptions)
343343
suite.False(parsedFinalizeResp.SessionsInvalidated)
344344

345345
// Verify account was created but not verified
@@ -490,7 +490,7 @@ func (suite *AccountsTestSuite) TestChangePassword() {
490490

491491
var changeFinalizeResp controllers.PasswordFinalizeResponse
492492
util.DecodeJSONTestResponse(suite.T(), resp.Body, &changeFinalizeResp)
493-
suite.False(changeFinalizeResp.RequiresTwoFA)
493+
suite.Nil(changeFinalizeResp.TwoFAOptions)
494494
suite.False(changeFinalizeResp.RequiresEmailVerification)
495495
suite.Equal(sessionInvalidation, changeFinalizeResp.SessionsInvalidated)
496496

controllers/auth.go

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ type LoginFinalizeRequest struct {
6969
type LoginFinalizeResponse struct {
7070
// Authentication token for future requests
7171
AuthToken *string `json:"authToken"`
72-
// Indicates if 2FA verification is required before authentication is complete
73-
RequiresTwoFA bool `json:"requiresTwoFA"`
72+
// Two-factor authentication options available for this account
73+
TwoFAOptions *services.TwoFAOptions `json:"twoFAOptions"`
7474
}
7575

7676
// @Description Response containing validated token details
@@ -398,10 +398,16 @@ func (ac *AuthController) LoginFinalize(w http.ResponseWriter, r *http.Request)
398398
return
399399
}
400400

401-
response := LoginFinalizeResponse{
402-
RequiresTwoFA: loginState.IsTwoFAEnabled(),
403-
}
404-
if !loginState.IsTwoFAEnabled() {
401+
response := LoginFinalizeResponse{}
402+
403+
if loginState.IsTwoFAEnabled() {
404+
twoFAOptions, err := ac.twoFAService.PrepareChallenge(loginState)
405+
if err != nil {
406+
util.RenderErrorResponse(w, r, http.StatusInternalServerError, err)
407+
return
408+
}
409+
response.TwoFAOptions = twoFAOptions
410+
} else {
405411
// Create a session and return an auth token
406412
authToken, err := ac.createSessionAndToken(*loginState.AccountID, r.UserAgent())
407413
if err != nil {
@@ -463,7 +469,10 @@ func (ac *AuthController) LoginFinalize2FA(w http.ResponseWriter, r *http.Reques
463469

464470
// Process the 2FA authentication request
465471
if err := ac.twoFAService.ProcessChallenge(loginState, &requestData); err != nil {
466-
if errors.Is(err, util.ErrBadTOTPCode) || errors.Is(err, util.ErrBadRecoveryKey) || errors.Is(err, util.ErrTOTPCodeAlreadyUsed) {
472+
if errors.Is(err, util.ErrBadTOTPCode) ||
473+
errors.Is(err, util.ErrBadRecoveryKey) ||
474+
errors.Is(err, util.ErrTOTPCodeAlreadyUsed) ||
475+
errors.Is(err, util.ErrBadWebAuthnResponse) {
467476
util.RenderErrorResponse(w, r, http.StatusUnauthorized, err)
468477
return
469478
}

controllers/auth_test.go

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package controllers_test
22

33
import (
44
"encoding/hex"
5+
"encoding/json"
56
"net/http"
67
"net/http/httptest"
78
"testing"
@@ -13,7 +14,9 @@ import (
1314
"github.com/brave/accounts/services"
1415
"github.com/brave/accounts/util"
1516
"github.com/bytemare/opaque"
17+
"github.com/descope/virtualwebauthn"
1618
"github.com/go-chi/chi/v5"
19+
"github.com/go-webauthn/webauthn/protocol"
1720
"github.com/golang-jwt/jwt/v5"
1821
"github.com/google/uuid"
1922
"github.com/pquerna/otp"
@@ -27,6 +30,7 @@ type AuthTestSuite struct {
2730
ds *datastore.Datastore
2831
keyServiceDs *datastore.Datastore
2932
jwtService *services.JWTService
33+
twoFAService *services.TwoFAService
3034
account *datastore.Account
3135
controller *controllers.AuthController
3236
router *chi.Mux
@@ -65,8 +69,8 @@ func (suite *AuthTestSuite) SetupTest() {
6569
suite.Require().NoError(err)
6670
opaqueService, err := services.NewOpaqueService(suite.ds, false)
6771
suite.Require().NoError(err)
68-
twoFAService := services.NewTwoFAService(suite.ds, false)
69-
suite.controller = controllers.NewAuthController(opaqueService, suite.jwtService, twoFAService, suite.ds, &MockSESService{})
72+
suite.twoFAService = services.NewTwoFAService(suite.ds, false)
73+
suite.controller = controllers.NewAuthController(opaqueService, suite.jwtService, suite.twoFAService, suite.ds, &MockSESService{})
7074

7175
suite.account, err = suite.ds.GetOrCreateAccount("test@example.com")
7276
suite.Require().NoError(err)
@@ -245,7 +249,7 @@ func (suite *AuthTestSuite) performLoginSteps() (*controllers.LoginFinalizeRespo
245249
func (suite *AuthTestSuite) TestAuthLogin() {
246250
finalizeResp, _ := suite.performLoginSteps()
247251
suite.NotEmpty(finalizeResp.AuthToken)
248-
suite.False(finalizeResp.RequiresTwoFA)
252+
suite.Nil(finalizeResp.TwoFAOptions)
249253

250254
req := httptest.NewRequest("GET", "/v2/auth/validate", nil)
251255
req.Header.Add("Authorization", "Bearer "+*finalizeResp.AuthToken)
@@ -484,7 +488,9 @@ func (suite *AuthTestSuite) TestAuth2FAWithTOTPCode() {
484488
finalizeResp, loginToken := suite.performLoginSteps()
485489

486490
// Verify we need 2FA
487-
suite.True(finalizeResp.RequiresTwoFA)
491+
suite.Require().NotNil(finalizeResp.TwoFAOptions)
492+
suite.True(finalizeResp.TwoFAOptions.TOTPEnabled)
493+
suite.Nil(finalizeResp.TwoFAOptions.WebAuthnRequest)
488494
suite.Nil(finalizeResp.AuthToken)
489495

490496
// Try using invalid TOTP code first
@@ -520,7 +526,9 @@ func (suite *AuthTestSuite) TestAuth2FAWithTOTPCode() {
520526

521527
// Perform login steps again to get a new login state
522528
finalizeResp, loginToken = suite.performLoginSteps()
523-
suite.True(finalizeResp.RequiresTwoFA)
529+
suite.Require().NotNil(finalizeResp.TwoFAOptions)
530+
suite.True(finalizeResp.TwoFAOptions.TOTPEnabled)
531+
suite.Nil(finalizeResp.TwoFAOptions.WebAuthnRequest)
524532
suite.Nil(finalizeResp.AuthToken)
525533

526534
// Try to reuse the same code with the new login state
@@ -541,6 +549,7 @@ func (suite *AuthTestSuite) TestAuth2FAWithTOTPCode() {
541549
account, err := suite.ds.GetOrCreateAccount(suite.account.Email)
542550
suite.Require().NoError(err)
543551
suite.True(account.TOTPEnabled)
552+
suite.False(account.WebAuthnEnabled)
544553
suite.NotNil(account.RecoveryKeyHash)
545554
}
546555

@@ -568,7 +577,9 @@ func (suite *AuthTestSuite) TestAuth2FAWithRecoveryKey() {
568577
finalizeResp, loginToken := suite.performLoginSteps()
569578

570579
// Verify we need 2FA
571-
suite.True(finalizeResp.RequiresTwoFA)
580+
suite.Require().NotNil(finalizeResp.TwoFAOptions)
581+
suite.True(finalizeResp.TwoFAOptions.TOTPEnabled)
582+
suite.Nil(finalizeResp.TwoFAOptions.WebAuthnRequest)
572583
suite.Nil(finalizeResp.AuthToken)
573584

574585
// Test 2FA with bad recovery key
@@ -606,10 +617,11 @@ func (suite *AuthTestSuite) TestAuth2FAWithRecoveryKey() {
606617
account, err := suite.ds.GetOrCreateAccount(suite.account.Email)
607618
suite.Require().NoError(err)
608619
suite.False(account.TOTPEnabled)
620+
suite.False(account.WebAuthnEnabled)
609621
suite.Nil(account.RecoveryKeyHash)
610622

611623
finalizeResp, _ = suite.performLoginSteps()
612-
suite.False(finalizeResp.RequiresTwoFA)
624+
suite.Nil(finalizeResp.TwoFAOptions)
613625
suite.NotNil(finalizeResp.AuthToken)
614626

615627
validateReq = httptest.NewRequest("GET", "/v2/auth/validate", nil)
@@ -618,6 +630,78 @@ func (suite *AuthTestSuite) TestAuth2FAWithRecoveryKey() {
618630
suite.Equal(http.StatusOK, validateResp.Code)
619631
}
620632

633+
func (suite *AuthTestSuite) TestAuth2FAWithWebAuthn() {
634+
// Add a WebAuthn credential for the account
635+
authenticator := virtualwebauthn.NewAuthenticator()
636+
registeredCredential := addWebAuthnCredential(suite.T(), suite.twoFAService, suite.ds, authenticator, suite.account, "YubiKey 5C")
637+
638+
// Create an unregistered credential for testing failure case
639+
unregisteredCredential := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
640+
641+
// Perform login initialization and finalization
642+
finalizeResp, loginToken := suite.performLoginSteps()
643+
644+
// Verify we need 2FA
645+
suite.Require().NotNil(finalizeResp.TwoFAOptions)
646+
suite.Require().NotNil(finalizeResp.TwoFAOptions.WebAuthnRequest)
647+
suite.Nil(finalizeResp.AuthToken)
648+
649+
// Try with unregistered credential first
650+
rp := createWebAuthnRelyingParty()
651+
652+
assertionOptionsJSON, err := json.Marshal(finalizeResp.TwoFAOptions.WebAuthnRequest)
653+
suite.Require().NoError(err)
654+
655+
parsedAssertionOptions, err := virtualwebauthn.ParseAssertionOptions(string(assertionOptionsJSON))
656+
suite.Require().NoError(err)
657+
658+
// Use unregistered credential - should fail
659+
unregisteredAssertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, unregisteredCredential, *parsedAssertionOptions)
660+
661+
var unregisteredWebAuthnResponse protocol.CredentialAssertionResponse
662+
err = json.Unmarshal([]byte(unregisteredAssertionResponse), &unregisteredWebAuthnResponse)
663+
suite.Require().NoError(err)
664+
665+
invalidRequest := services.TwoFAAuthRequest{
666+
WebAuthnResponse: &unregisteredWebAuthnResponse,
667+
}
668+
req := util.CreateJSONTestRequest("/v2/auth/login/finalize_2fa", invalidRequest)
669+
req.Header.Set("Authorization", "Bearer "+loginToken)
670+
resp := util.ExecuteTestRequest(req, suite.router)
671+
672+
// Should get unauthorized with unregistered credential
673+
suite.Equal(http.StatusUnauthorized, resp.Code)
674+
util.AssertErrorResponseCode(suite.T(), resp, util.ErrBadWebAuthnResponse.Code)
675+
676+
// Now use the registered credential - should succeed
677+
registeredAssertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, registeredCredential, *parsedAssertionOptions)
678+
679+
var registeredWebAuthnResponse protocol.CredentialAssertionResponse
680+
err = json.Unmarshal([]byte(registeredAssertionResponse), &registeredWebAuthnResponse)
681+
suite.Require().NoError(err)
682+
683+
validRequest := services.TwoFAAuthRequest{
684+
WebAuthnResponse: &registeredWebAuthnResponse,
685+
}
686+
req = util.CreateJSONTestRequest("/v2/auth/login/finalize_2fa", validRequest)
687+
req.Header.Set("Authorization", "Bearer "+loginToken)
688+
resp = util.ExecuteTestRequest(req, suite.router)
689+
690+
// Should succeed with registered credential
691+
suite.Equal(http.StatusOK, resp.Code)
692+
693+
var parsedTwoFAResp controllers.LoginFinalize2FAResponse
694+
util.DecodeJSONTestResponse(suite.T(), resp.Body, &parsedTwoFAResp)
695+
suite.NotEmpty(parsedTwoFAResp.AuthToken)
696+
suite.False(parsedTwoFAResp.TwoFADisabled)
697+
698+
// Verify the auth token works
699+
validateReq := httptest.NewRequest("GET", "/v2/auth/validate", nil)
700+
validateReq.Header.Add("Authorization", "Bearer "+parsedTwoFAResp.AuthToken)
701+
validateResp := util.ExecuteTestRequest(validateReq, suite.router)
702+
suite.Equal(http.StatusOK, validateResp.Code)
703+
}
704+
621705
func TestAuthTestSuite(t *testing.T) {
622706
t.Run("NoKeyService", func(t *testing.T) {
623707
suite.Run(t, NewAuthTestSuite(false))

0 commit comments

Comments
 (0)