Skip to content

Commit

Permalink
Scoped WebAuthn: MFA extension flow (#36667)
Browse files Browse the repository at this point in the history
* Use SessionData with extensions in Webauthn flow.

* Pass MFAChallengeExtensions through webauthn flow.

* Opportunistically enforce Webauthn challenge scope.

* Don't delete webauthn session data when reuse is allowed.

* Return more login data from webauthn flow.

* Enforce reuse when provided by the caller.

* Address comments.

* Fix test.

* Add unit test for scope and reuse.

* use pointer for challenge extension parameters.

* Address comments.
  • Loading branch information
Joerger authored Jan 20, 2024
1 parent 8928ff5 commit 6e1955a
Show file tree
Hide file tree
Showing 6 changed files with 400 additions and 69 deletions.
23 changes: 18 additions & 5 deletions lib/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import (
"github.com/gravitational/teleport/api/constants"
apidefaults "github.com/gravitational/teleport/api/defaults"
"github.com/gravitational/teleport/api/gen/proto/go/assist/v1"
mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1"
"github.com/gravitational/teleport/api/internalutils/stream"
"github.com/gravitational/teleport/api/metadata"
"github.com/gravitational/teleport/api/types"
Expand Down Expand Up @@ -5806,7 +5807,12 @@ func (a *Server) mfaAuthChallenge(ctx context.Context, user string, passwordless
Webauthn: webConfig,
Identity: wanlib.WithDevices(a.Services, groupedDevs.Webauthn),
}
assertion, err := webLogin.Begin(ctx, user)
// TODO(Joerger): Get extensions from caller.
ext := &mfav1.ChallengeExtensions{
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_UNSPECIFIED,
AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_NO,
}
assertion, err := webLogin.Begin(ctx, user, ext)
if err != nil {
return nil, trace.Wrap(err)
}
Expand Down Expand Up @@ -5919,25 +5925,32 @@ func (a *Server) ValidateMFAAuthResponse(ctx context.Context, resp *proto.MFAAut
}

assertionResp := wantypes.CredentialAssertionResponseFromProto(res.Webauthn)
var dev *types.MFADevice
var loginData *wanlib.LoginData
if passwordless {
webLogin := &wanlib.PasswordlessFlow{
Webauthn: webConfig,
Identity: a.Services,
}
dev, user, err = webLogin.Finish(ctx, assertionResp)
loginData, err = webLogin.Finish(ctx, assertionResp)
} else {
webLogin := &wanlib.LoginFlow{
U2F: u2f,
Webauthn: webConfig,
Identity: a.Services,
}
dev, err = webLogin.Finish(ctx, user, wantypes.CredentialAssertionResponseFromProto(res.Webauthn))
// TODO(Joerger): Get extensions from caller.
ext := &mfav1.ChallengeExtensions{
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_UNSPECIFIED,
AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_NO,
}
loginData, err = webLogin.Finish(ctx, user, wantypes.CredentialAssertionResponseFromProto(res.Webauthn), ext)
}
if err != nil {
return nil, "", trace.AccessDenied("MFA response validation failed: %v", err)
}
return dev, user, nil
// TODO(Joerger): Refactor Validate to also return AllowReuse
// for further validation by caller when necessary.
return loginData.Device, loginData.User, nil

case *proto.MFAAuthenticateResponse_TOTP:
dev, err := a.checkOTP(user, res.TOTP.Code)
Expand Down
94 changes: 72 additions & 22 deletions lib/auth/webauthn/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
"github.com/gravitational/trace"
log "github.com/sirupsen/logrus"

mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1"
"github.com/gravitational/teleport/api/types"
wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes"
)
Expand Down Expand Up @@ -66,7 +67,16 @@ type loginFlow struct {
sessionData sessionIdentity
}

func (f *loginFlow) begin(ctx context.Context, user string, passwordless bool) (*wantypes.CredentialAssertion, error) {
func (f *loginFlow) begin(ctx context.Context, user string, challengeExtensions *mfav1.ChallengeExtensions) (*wantypes.CredentialAssertion, error) {
if challengeExtensions == nil {
return nil, trace.BadParameter("requested challenge extensions must be supplied.")
}

if challengeExtensions.AllowReuse == mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_YES && challengeExtensions.Scope != mfav1.ChallengeScope_CHALLENGE_SCOPE_ADMIN_ACTION {
return nil, trace.BadParameter("mfa challenges with scope %s cannot allow reuse", challengeExtensions.Scope)
}

passwordless := challengeExtensions.Scope == mfav1.ChallengeScope_CHALLENGE_SCOPE_PASSWORDLESS_LOGIN
if user == "" && !passwordless {
return nil, trace.BadParameter("user required")
}
Expand Down Expand Up @@ -166,7 +176,7 @@ func (f *loginFlow) begin(ctx context.Context, user string, passwordless bool) (
if err != nil {
return nil, trace.Wrap(err)
}
// TODO(Joerger): set challenge extensions from caller
sd.ChallengeExtensions = challengeExtensions

if err := f.sessionData.Upsert(ctx, user, sd); err != nil {
return nil, trace.Wrap(err)
Expand All @@ -186,56 +196,73 @@ func (f *loginFlow) getWebID(ctx context.Context, user string) ([]byte, error) {
return wla.UserID, nil
}

func (f *loginFlow) finish(ctx context.Context, user string, resp *wantypes.CredentialAssertionResponse, passwordless bool) (*types.MFADevice, string, error) {
// LoginData is data gathered from a successful webauthn login.
type LoginData struct {
// User is the Teleport user.
User string
// Device is the MFA device used to authenticate the user.
Device *types.MFADevice
// AllowReuse is whether the webauthn challenge used for this login
// can be reused by the user for subsequent logins, until it expires.
AllowReuse mfav1.ChallengeAllowReuse
}

func (f *loginFlow) finish(ctx context.Context, user string, resp *wantypes.CredentialAssertionResponse, requiredExtensions *mfav1.ChallengeExtensions) (*LoginData, error) {
if requiredExtensions == nil {
return nil, trace.BadParameter("requested challenge extensions must be supplied.")
}

passwordless := requiredExtensions.Scope == mfav1.ChallengeScope_CHALLENGE_SCOPE_PASSWORDLESS_LOGIN

switch {
case user == "" && !passwordless:
return nil, "", trace.BadParameter("user required")
return nil, trace.BadParameter("user required")
case resp == nil:
// resp != nil is good enough to proceed, we leave remaining validations to
// duo-labs/webauthn.
return nil, "", trace.BadParameter("credential assertion response required")
return nil, trace.BadParameter("credential assertion response required")
}

parsedResp, err := parseCredentialResponse(resp)
if err != nil {
return nil, "", trace.Wrap(err)
return nil, trace.Wrap(err)
}

origin := parsedResp.Response.CollectedClientData.Origin
if err := validateOrigin(origin, f.Webauthn.RPID); err != nil {
log.WithError(err).Debugf("WebAuthn: origin validation failed")
return nil, "", trace.Wrap(err)
return nil, trace.Wrap(err)
}

var webID []byte
if passwordless {
webID = parsedResp.Response.UserHandle
if len(webID) == 0 {
return nil, "", trace.BadParameter("webauthn user handle required for passwordless")
return nil, trace.BadParameter("webauthn user handle required for passwordless")
}

// Fetch user from WebAuthn UserHandle (aka User ID).
teleportUser, err := f.identity.GetTeleportUserByWebauthnID(ctx, webID)
if err != nil {
return nil, "", trace.Wrap(err)
return nil, trace.Wrap(err)
}
user = teleportUser
} else {
webID, err = f.getWebID(ctx, user)
if err != nil {
return nil, "", trace.Wrap(err)
return nil, trace.Wrap(err)
}
}

// Find the device used to sign the credentials. It must be a previously
// registered device.
devices, err := f.identity.GetMFADevices(ctx, user, false /* withSecrets */)
if err != nil {
return nil, "", trace.Wrap(err)
return nil, trace.Wrap(err)
}
dev, ok := findDeviceByID(devices, parsedResp.RawID)
if !ok {
return nil, "", trace.BadParameter(
return nil, trace.BadParameter(
"unknown device credential: %q", base64.RawURLEncoding.EncodeToString(parsedResp.RawID))
}

Expand All @@ -246,7 +273,7 @@ func (f *loginFlow) finish(ctx context.Context, user string, resp *wantypes.Cred
rpID := f.Webauthn.RPID
switch {
case dev.GetU2F() != nil && f.U2F == nil:
return nil, "", trace.BadParameter("U2F device attempted login, but U2F configuration not present")
return nil, trace.BadParameter("U2F device attempted login, but U2F configuration not present")
case dev.GetU2F() != nil:
rpID = f.U2F.AppID
}
Expand All @@ -257,8 +284,23 @@ func (f *loginFlow) finish(ctx context.Context, user string, resp *wantypes.Cred
challenge := parsedResp.Response.CollectedClientData.Challenge
sd, err := f.sessionData.Get(ctx, user, challenge)
if err != nil {
return nil, "", trace.Wrap(err)
return nil, trace.Wrap(err)
}

// Check if the given scope is satisfied by the challenge scope.
if requiredExtensions.Scope != sd.ChallengeExtensions.Scope && requiredExtensions.Scope != mfav1.ChallengeScope_CHALLENGE_SCOPE_UNSPECIFIED {
// old clients do not yet provide a scope, so we only enforce scope opportunistically.
// TODO(Joerger): DELETE IN v16.0.0
if sd.ChallengeExtensions.Scope != mfav1.ChallengeScope_CHALLENGE_SCOPE_UNSPECIFIED {
return nil, trace.AccessDenied("required scope %q is not satisfied by the given webauthn session with scope %q", requiredExtensions.Scope, sd.ChallengeExtensions.Scope)
}
}

// If this session is reusable, but this login forbids reusable sessions, return an error.
if requiredExtensions.AllowReuse == mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_NO && sd.ChallengeExtensions.AllowReuse == mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_YES {
return nil, trace.AccessDenied("the given webauthn session allows reuse, but reuse is not permitted in this context")
}

sessionData := wantypes.SessionDataToProtocol(sd)

// Make sure _all_ credentials in the session are accounted for by the user.
Expand All @@ -281,7 +323,7 @@ func (f *loginFlow) finish(ctx context.Context, user string, resp *wantypes.Cred
requireUserVerification: passwordless,
})
if err != nil {
return nil, "", trace.Wrap(err)
return nil, trace.Wrap(err)
}

var credential *wan.Credential
Expand All @@ -292,7 +334,7 @@ func (f *loginFlow) finish(ctx context.Context, user string, resp *wantypes.Cred
credential, err = web.ValidateLogin(u, *sessionData, parsedResp)
}
if err != nil {
return nil, "", trace.Wrap(err)
return nil, trace.Wrap(err)
}
if credential.Authenticator.CloneWarning {
log.Warnf(
Expand All @@ -301,7 +343,7 @@ func (f *loginFlow) finish(ctx context.Context, user string, resp *wantypes.Cred

// Update last used timestamp and device counter.
if err := setCounterAndTimestamps(dev, credential); err != nil {
return nil, "", trace.Wrap(err)
return nil, trace.Wrap(err)
}
// Retroactively write the credential RPID, now that it cleared authn.
if webDev := dev.GetWebauthn(); webDev != nil && webDev.CredentialRpId == "" {
Expand All @@ -310,16 +352,24 @@ func (f *loginFlow) finish(ctx context.Context, user string, resp *wantypes.Cred
}

if err := f.identity.UpsertMFADevice(ctx, user, dev); err != nil {
return nil, "", trace.Wrap(err)
return nil, trace.Wrap(err)
}

// The user just solved the challenge, so let's make sure it won't be used
// again.
if err := f.sessionData.Delete(ctx, user, challenge); err != nil {
log.Warnf("WebAuthn: failed to delete login SessionData for user %v (passwordless = %v)", user, passwordless)
// again, unless reuse is explicitly allowed.
// Note that even reusable sessions are deleted when their expiration time
// passes.
if sd.ChallengeExtensions.AllowReuse != mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_YES {
if err := f.sessionData.Delete(ctx, user, challenge); err != nil {
log.Warnf("WebAuthn: failed to delete login SessionData for user %v (scope = %s)", user, sd.ChallengeExtensions.Scope)
}
}

return dev, user, nil
return &LoginData{
User: user,
Device: dev,
AllowReuse: sd.ChallengeExtensions.AllowReuse,
}, nil
}

func parseCredentialResponse(resp *wantypes.CredentialAssertionResponse) (*protocol.ParsedCredentialAssertionData, error) {
Expand Down
24 changes: 14 additions & 10 deletions lib/auth/webauthn/login_mfa.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ import (
"context"
"errors"

"github.com/gravitational/trace"

mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1"
"github.com/gravitational/teleport/api/types"
wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes"
)
Expand Down Expand Up @@ -93,29 +92,34 @@ type LoginFlow struct {
// assertion.
// As a side effect Begin may assign (and record in storage) a WebAuthn ID for
// the user.
func (f *LoginFlow) Begin(ctx context.Context, user string) (*wantypes.CredentialAssertion, error) {
// Requested challenge extensions will be stored on the stored webauthn challenge
// record. These extensions indicate additional rules/properties of the webauthn
// challenge that can be validated in the final login step.
func (f *LoginFlow) Begin(ctx context.Context, user string, challengeExtensions *mfav1.ChallengeExtensions) (*wantypes.CredentialAssertion, error) {
lf := &loginFlow{
U2F: f.U2F,
Webauthn: f.Webauthn,
identity: mfaIdentity{f.Identity},
sessionData: (*userSessionStorage)(f),
}
return lf.begin(ctx, user, false /* passwordless */)
return lf.begin(ctx, user, challengeExtensions)
}

// Finish is the second and last step of the LoginFlow.
// It returns the MFADevice used to solve the challenge. If login is successful,
// Finish has the side effect of updating the counter and last used timestamp of
// the returned device.
func (f *LoginFlow) Finish(ctx context.Context, user string, resp *wantypes.CredentialAssertionResponse) (*types.MFADevice, error) {
// Expected challenge extensions will be validated against the stored webauthn
// challenge record.
// It returns the MFADevice used to solve the challenge, the associated Teleport
// user name, and other login properties. If login is successful, Finish has the
// side effect of updating the counter and last used timestamp of the MFADevice
// used.
func (f *LoginFlow) Finish(ctx context.Context, user string, resp *wantypes.CredentialAssertionResponse, requiredExtensions *mfav1.ChallengeExtensions) (*LoginData, error) {
lf := &loginFlow{
U2F: f.U2F,
Webauthn: f.Webauthn,
identity: mfaIdentity{f.Identity},
sessionData: (*userSessionStorage)(f),
}
dev, _, err := lf.finish(ctx, user, resp, false /* passwordless */)
return dev, trace.Wrap(err)
return lf.finish(ctx, user, resp, requiredExtensions)
}

type mfaIdentity struct {
Expand Down
15 changes: 12 additions & 3 deletions lib/auth/webauthn/login_passwordless.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"encoding/base64"
"errors"

mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1"
"github.com/gravitational/teleport/api/types"
wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes"
)
Expand Down Expand Up @@ -54,19 +55,27 @@ func (f *PasswordlessFlow) Begin(ctx context.Context) (*wantypes.CredentialAsser
identity: passwordlessIdentity{f.Identity},
sessionData: (*globalSessionStorage)(f),
}
return lf.begin(ctx, "" /* user */, true /* passwordless */)
chalExt := &mfav1.ChallengeExtensions{
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_PASSWORDLESS_LOGIN,
AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_NO,
}
return lf.begin(ctx, "" /* user */, chalExt)
}

// Finish is the last step of the passwordless login flow.
// It works similarly to LoginFlow.Finish, but the user identity is established
// via the response UserHandle, instead of an explicit Teleport username.
func (f *PasswordlessFlow) Finish(ctx context.Context, resp *wantypes.CredentialAssertionResponse) (*types.MFADevice, string, error) {
func (f *PasswordlessFlow) Finish(ctx context.Context, resp *wantypes.CredentialAssertionResponse) (*LoginData, error) {
lf := &loginFlow{
Webauthn: f.Webauthn,
identity: passwordlessIdentity{f.Identity},
sessionData: (*globalSessionStorage)(f),
}
return lf.finish(ctx, "" /* user */, resp, true /* passwordless */)
requiredExt := &mfav1.ChallengeExtensions{
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_PASSWORDLESS_LOGIN,
AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_NO,
}
return lf.finish(ctx, "" /* user */, resp, requiredExt)
}

type passwordlessIdentity struct {
Expand Down
Loading

0 comments on commit 6e1955a

Please sign in to comment.