Skip to content

Commit

Permalink
Implement global SessionData storage (#10287)
Browse files Browse the repository at this point in the history
Global session data is used to store passwordless challenges. Per-user session
data cannot be used, as the user is now known by the time the challenge is
issued.

I elected to keep both global and per-user session data storage for now, as
per-user is less subject to DDoS-type attacks than global.

#9160
  • Loading branch information
codingllama authored Feb 14, 2022
1 parent b3994e3 commit 8806857
Show file tree
Hide file tree
Showing 4 changed files with 176 additions and 20 deletions.
5 changes: 5 additions & 0 deletions lib/defaults/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
15 changes: 15 additions & 0 deletions lib/services/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
97 changes: 77 additions & 20 deletions lib/services/local/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"
)
79 changes: 79 additions & 0 deletions lib/services/local/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 := &params[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
}
}

0 comments on commit 8806857

Please sign in to comment.