diff --git a/lib/defaults/defaults.go b/lib/defaults/defaults.go index 64578a3abd663..677db0fd0732d 100644 --- a/lib/defaults/defaults.go +++ b/lib/defaults/defaults.go @@ -578,6 +578,11 @@ const ( // WebauthnChallengeTimeout is the timeout for ongoing Webauthn authentication // or registration challenges. WebauthnChallengeTimeout = 5 * time.Minute + // WebauthnGlobalChallengeTimeout is the timeout for global authentication + // challenges. + // Stricter than WebauthnChallengeTimeout because global challenges are + // anonymous. + WebauthnGlobalChallengeTimeout = 1 * time.Minute ) const ( diff --git a/lib/services/identity.go b/lib/services/identity.go index a9f62afaa76ff..8c5f92ab47680 100644 --- a/lib/services/identity.go +++ b/lib/services/identity.go @@ -145,6 +145,21 @@ type Identity interface { // not expired. DeleteWebauthnSessionData(ctx context.Context, user, sessionID string) error + // UpsertGlobalWebauthnSessionData creates or updates WebAuthn session data in + // storage, for the purpose of later verifying an authentication challenge. + // Session data is expected to expire according to backend settings. + // Used for passwordless challenges. + UpsertGlobalWebauthnSessionData(ctx context.Context, scope, id string, sd *wantypes.SessionData) error + + // GetGlobalWebauthnSessionData retrieves previously-stored session data by ID, + // if it exists and has not expired. + // Used for passwordless challenges. + GetGlobalWebauthnSessionData(ctx context.Context, scope, id string) (*wantypes.SessionData, error) + + // DeleteGlobalWebauthnSessionData deletes session data by ID, if it exists + // and has not expired. + DeleteGlobalWebauthnSessionData(ctx context.Context, scope, id string) error + // UpsertMFADevice upserts an MFA device for the user. UpsertMFADevice(ctx context.Context, user string, d *types.MFADevice) error diff --git a/lib/services/local/users.go b/lib/services/local/users.go index fbf2bfd26df77..0366d4dcb898a 100644 --- a/lib/services/local/users.go +++ b/lib/services/local/users.go @@ -659,6 +659,61 @@ func sessionDataKey(user, sessionID string) []byte { return backend.Key(webPrefix, usersPrefix, user, webauthnSessionData, sessionID) } +func (s *IdentityService) UpsertGlobalWebauthnSessionData(ctx context.Context, scope, id string, sd *wantypes.SessionData) error { + switch { + case scope == "": + return trace.BadParameter("missing parameter scope") + case id == "": + return trace.BadParameter("missing parameter id") + case sd == nil: + return trace.BadParameter("missing parameter sd") + } + + // TODO(codingllama): Limit number of in-flight challenges. + + value, err := json.Marshal(sd) + if err != nil { + return trace.Wrap(err) + } + _, err = s.Put(ctx, backend.Item{ + Key: globalSessionDataKey(scope, id), + Value: value, + Expires: s.Clock().Now().UTC().Add(defaults.WebauthnGlobalChallengeTimeout), + }) + return trace.Wrap(err) +} + +func (s *IdentityService) GetGlobalWebauthnSessionData(ctx context.Context, scope, id string) (*wantypes.SessionData, error) { + switch { + case scope == "": + return nil, trace.BadParameter("missing parameter scope") + case id == "": + return nil, trace.BadParameter("missing parameter id") + } + + item, err := s.Get(ctx, globalSessionDataKey(scope, id)) + if err != nil { + return nil, trace.Wrap(err) + } + sd := &wantypes.SessionData{} + return sd, trace.Wrap(json.Unmarshal(item.Value, sd)) +} + +func (s *IdentityService) DeleteGlobalWebauthnSessionData(ctx context.Context, scope, id string) error { + switch { + case scope == "": + return trace.BadParameter("missing parameter scope") + case id == "": + return trace.BadParameter("missing parameter id") + } + + return trace.Wrap(s.Delete(ctx, globalSessionDataKey(scope, id))) +} + +func globalSessionDataKey(scope, id string) []byte { + return backend.Key(webauthnPrefix, webauthnGlobalSessionData, scope, id) +} + func (s *IdentityService) UpsertMFADevice(ctx context.Context, user string, d *types.MFADevice) error { if user == "" { return trace.BadParameter("missing parameter user") @@ -1319,24 +1374,26 @@ func (s recoveryAttemptsChronologically) Swap(i, j int) { } const ( - webPrefix = "web" - usersPrefix = "users" - sessionsPrefix = "sessions" - attemptsPrefix = "attempts" - pwdPrefix = "pwd" - hotpPrefix = "hotp" - connectorsPrefix = "connectors" - oidcPrefix = "oidc" - samlPrefix = "saml" - githubPrefix = "github" - requestsPrefix = "requests" - u2fRegChalPrefix = "adduseru2fchallenges" - usedTOTPPrefix = "used_totp" - usedTOTPTTL = 30 * time.Second - mfaDevicePrefix = "mfa" - u2fSignChallengePrefix = "u2fsignchallenge" - webauthnLocalAuthPrefix = "webauthnlocalauth" - webauthnSessionData = "webauthnsessiondata" - recoveryCodesPrefix = "recoverycodes" - recoveryAttemptsPrefix = "recoveryattempts" + webPrefix = "web" + usersPrefix = "users" + sessionsPrefix = "sessions" + attemptsPrefix = "attempts" + pwdPrefix = "pwd" + hotpPrefix = "hotp" + connectorsPrefix = "connectors" + oidcPrefix = "oidc" + samlPrefix = "saml" + githubPrefix = "github" + requestsPrefix = "requests" + u2fRegChalPrefix = "adduseru2fchallenges" + usedTOTPPrefix = "used_totp" + usedTOTPTTL = 30 * time.Second + mfaDevicePrefix = "mfa" + u2fSignChallengePrefix = "u2fsignchallenge" + webauthnPrefix = "webauthn" + webauthnGlobalSessionData = "sessionData" + webauthnLocalAuthPrefix = "webauthnlocalauth" + webauthnSessionData = "webauthnsessiondata" + recoveryCodesPrefix = "recoverycodes" + recoveryAttemptsPrefix = "recoveryattempts" ) diff --git a/lib/services/local/users_test.go b/lib/services/local/users_test.go index fdcc1eb4c9b89..65f3b30fb67b5 100644 --- a/lib/services/local/users_test.go +++ b/lib/services/local/users_test.go @@ -18,9 +18,11 @@ package local_test import ( "context" + "encoding/base64" "testing" "time" + "github.com/duo-labs/webauthn/protocol" "github.com/google/go-cmp/cmp" "github.com/google/uuid" "github.com/gravitational/teleport/api/types" @@ -563,3 +565,80 @@ func TestIdentityService_WebauthnSessionDataCRUD(t *testing.T) { require.NoError(t, err) // Other keys preserved } } + +func TestIdentityService_GlobalWebauthnSessionDataCRUD(t *testing.T) { + t.Parallel() + identity, _ := newIdentityService(t) + + user1Login1 := &wantypes.SessionData{ + Challenge: []byte("challenge1"), + UserId: []byte("user1-web-id"), + UserVerification: string(protocol.VerificationRequired), + } + user1Login2 := &wantypes.SessionData{ + Challenge: []byte("challenge2"), + UserId: []byte("user1-web-id"), + UserVerification: string(protocol.VerificationRequired), + } + user1Registration := &wantypes.SessionData{ + Challenge: []byte("challenge3"), + UserId: []byte("user1-web-id"), + ResidentKey: true, + UserVerification: string(protocol.VerificationRequired), + } + user2Login := &wantypes.SessionData{ + Challenge: []byte("challenge4"), + UserId: []byte("user2-web-id"), + ResidentKey: true, + UserVerification: string(protocol.VerificationRequired), + } + + const scopeLogin = "login" + // Registration doesn't typically use global session data, used here for + // testing purposes only. + const scopeRegister = "register" + params := []struct { + scope, id string + sd *wantypes.SessionData + }{ + {scope: scopeLogin, id: base64.RawURLEncoding.EncodeToString(user1Login1.Challenge), sd: user1Login1}, + {scope: scopeLogin, id: base64.RawURLEncoding.EncodeToString(user1Login2.Challenge), sd: user1Login2}, + {scope: scopeRegister, id: base64.RawURLEncoding.EncodeToString(user1Registration.Challenge), sd: user1Registration}, + {scope: scopeLogin, id: base64.RawURLEncoding.EncodeToString(user2Login.Challenge), sd: user2Login}, + } + + // Verify create. + ctx := context.Background() + for _, p := range params { + require.NoError(t, identity.UpsertGlobalWebauthnSessionData(ctx, p.scope, p.id, p.sd)) + } + + // Verify read. + for _, p := range params { + got, err := identity.GetGlobalWebauthnSessionData(ctx, p.scope, p.id) + require.NoError(t, err) + if diff := cmp.Diff(p.sd, got); diff != "" { + t.Errorf("GetGlobalWebauthnSessionData() mismatch (-want +got):\n%s", diff) + } + } + + // Verify update. + p0 := ¶ms[0] + p0.sd.UserVerification = "" + require.NoError(t, identity.UpsertGlobalWebauthnSessionData(ctx, p0.scope, p0.id, p0.sd)) + got, err := identity.GetGlobalWebauthnSessionData(ctx, p0.scope, p0.id) + require.NoError(t, err) + if diff := cmp.Diff(p0.sd, got); diff != "" { + t.Errorf("GetGlobalWebauthnSessionData() mismatch (-want +got):\n%s", diff) + } + + // Verify deletion. + require.NoError(t, identity.DeleteGlobalWebauthnSessionData(ctx, p0.scope, p0.id)) + _, err = identity.GetGlobalWebauthnSessionData(ctx, p0.scope, p0.id) + require.True(t, trace.IsNotFound(err)) + params = params[1:] // Remove p0 from params + for _, p := range params { + _, err := identity.GetGlobalWebauthnSessionData(ctx, p.scope, p.id) + require.NoError(t, err) // Other keys preserved + } +}