Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/proto/teleport/legacy/types/events/events.proto
Original file line number Diff line number Diff line change
Expand Up @@ -3825,6 +3825,9 @@ message MFADeviceMetadata {
string DeviceID = 2 [(gogoproto.jsontag) = "mfa_device_uuid"];
// Type is the type of this MFA device.
string DeviceType = 3 [(gogoproto.jsontag) = "mfa_device_type"];
// MFAViaBrowser is true when the user used the Browser MFA flow to MFA in the
// browser to authenticate a CLI session.
bool MFAViaBrowser = 4 [(gogoproto.jsontag) = "mfa_via_browser,omitempty"];
Comment thread
zmb3 marked this conversation as resolved.
}

// MFADeviceAdd is emitted when a user adds an MFA device.
Expand Down
2,886 changes: 1,462 additions & 1,424 deletions api/types/events/events.pb.go

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions lib/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -8151,7 +8151,7 @@ func groupByDeviceType(devs []*types.MFADevice) devicesByType {
// Use only for registration purposes.
func (a *Server) validateMFAAuthResponseForRegister(ctx context.Context, resp *proto.MFAAuthenticateResponse, username string, requiredExtensions *mfav1.ChallengeExtensions) (hasDevices bool, err error) {
// Let users without a useable device go through registration.
if resp == nil || (resp.GetTOTP() == nil && resp.GetWebauthn() == nil && resp.GetSSO() == nil) {
if resp == nil || (resp.GetTOTP() == nil && resp.GetWebauthn() == nil && resp.GetSSO() == nil && resp.GetBrowser() == nil) {
devices, err := a.Services.GetMFADevices(ctx, username, false /* withSecrets */)
if err != nil {
return false, trace.Wrap(err)
Expand All @@ -8170,8 +8170,9 @@ func (a *Server) validateMFAAuthResponseForRegister(ctx context.Context, resp *p
hasTOTP := authPref.IsSecondFactorTOTPAllowed() && devsByType.TOTP
hasWebAuthn := authPref.IsSecondFactorWebauthnAllowed() && len(devsByType.Webauthn) > 0
hasSSO := authPref.IsSecondFactorSSOAllowed() && devsByType.SSO != nil
hasBrowser := authPref.GetAllowCLIAuthViaBrowser() && devsByType.Browser != nil

if hasTOTP || hasWebAuthn || hasSSO {
if hasTOTP || hasWebAuthn || hasSSO || hasBrowser {
return false, trace.BadParameter("second factor authentication required")
}

Expand Down Expand Up @@ -8240,6 +8241,7 @@ func (a *Server) ValidateMFAAuthResponse(
auditEvent.Code = events.ValidateMFAAuthResponseCode
auditEvent.Success = true
deviceMetadata := mfaDeviceEventMetadata(authData.Device)
deviceMetadata.MFAViaBrowser = authData.MFAViaBrowser
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love that I set the MFAViaBrowser field here. But, to add this field to the event via mfaDeviceEventMetadata I would have to add proto to MFADevice that isn't really related to an MFA device, or I could change mfaDeviceEventMetadata to accept authData and change everywhere that uses this function. Any strong opinions on this?

auditEvent.MFADevice = &deviceMetadata
auditEvent.ChallengeAllowReuse = authData.AllowReuse == mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_YES
}
Expand Down Expand Up @@ -8352,6 +8354,9 @@ func (a *Server) validateMFAAuthResponseInternal(
case *proto.MFAAuthenticateResponse_SSO:
mfaAuthData, err := a.VerifySSOMFASession(ctx, user, res.SSO.RequestId, res.SSO.Token, requiredExtensions)
return mfaAuthData, trace.Wrap(err)
case *proto.MFAAuthenticateResponse_Browser:
mfaAuthData, err := a.VerifyBrowserMFASession(ctx, user, res.Browser.RequestId, res.Browser.WebauthnResponse, requiredExtensions)
Comment thread
danielashare marked this conversation as resolved.
return mfaAuthData, trace.Wrap(err)
default:
return nil, trace.BadParameter("unknown or missing MFAAuthenticateResponse type %T", resp.Response)
}
Expand Down
2 changes: 1 addition & 1 deletion lib/auth/auth_with_roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -7521,7 +7521,7 @@ func (a *ServerWithRoles) CreateRegisterChallenge(ctx context.Context, req *prot

// Device trust: authorize device before issuing a register challenge without an MFA response or privilege token.
// This is an exceptional case for users registering their first MFA challenge through `tsh`.
if mfaResp := req.GetExistingMFAResponse(); mfaResp.GetTOTP() == nil && mfaResp.GetWebauthn() == nil {
if mfaResp := req.GetExistingMFAResponse(); mfaResp.GetTOTP() == nil && mfaResp.GetWebauthn() == nil && mfaResp.GetBrowser() == nil {
if err := a.enforceGlobalModeTrustedDevice(ctx); err != nil {
return nil, trace.Wrap(err, "device trust is required for users to register their first MFA device")
}
Expand Down
5 changes: 4 additions & 1 deletion lib/auth/authclient/clt.go
Original file line number Diff line number Diff line change
Expand Up @@ -1417,6 +1417,9 @@ type AuthenticateUserRequest struct {
Webauthn *wantypes.CredentialAssertionResponse `json:"webauthn,omitempty"`
// OTP is a password and second factor, used for MFA authentication
OTP *OTPCreds `json:"otp,omitempty"`
// BrowserMFA is a Browser MFA message that a CLI client has sent to the proxy
// containing a WebAuthn response for auth
BrowserMFA *proto.BrowserMFAResponse `json:"browser,omitempty"`
// Session is a web session credential used to authenticate web sessions
Session *SessionCreds `json:"session,omitempty"`
// ClientMetadata includes forwarded information about a client
Expand Down Expand Up @@ -1452,7 +1455,7 @@ func (a *AuthenticateUserRequest) CheckAndSetDefaults() error {
case a.Username == "" && a.Webauthn != nil: // OK, passwordless.
case a.Username == "":
return trace.BadParameter("missing parameter 'username'")
case a.Pass == nil && a.Webauthn == nil && a.OTP == nil && a.Session == nil && a.HeadlessAuthenticationID == "":
case a.Pass == nil && a.Webauthn == nil && a.OTP == nil && a.Session == nil && a.HeadlessAuthenticationID == "" && a.BrowserMFA == nil:
return trace.BadParameter("at least one authentication method is required")
}
return nil
Expand Down
120 changes: 120 additions & 0 deletions lib/auth/browser_mfa.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ import (

"github.com/gravitational/teleport/api/client/proto"
"github.com/gravitational/teleport/api/constants"
mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1"
webauthnpb "github.com/gravitational/teleport/api/types/webauthn"
"github.com/gravitational/teleport/lib/auth/internal/browsermfa"
"github.com/gravitational/teleport/lib/auth/mfatypes"
wanlib "github.com/gravitational/teleport/lib/auth/webauthn"
wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes"
"github.com/gravitational/teleport/lib/authz"
"github.com/gravitational/teleport/lib/client/sso"
Expand Down Expand Up @@ -105,3 +107,121 @@ func (a *Server) BeginBrowserMFAChallenge(ctx context.Context, params mfatypes.B

return browserChal, nil
}

// VerifyBrowserMFASession verifies that the given Browser MFA webauthn response matches an existing MFA session
// for the user and session ID. It also checks the required extensions, and finishes by deleting
// the MFA session if reuse is not allowed.
func (a *Server) VerifyBrowserMFASession(ctx context.Context, username, sessionID string, webauthnResponse *webauthnpb.CredentialAssertionResponse, requiredExtensions *mfav1.ChallengeExtensions) (*authz.MFAAuthData, error) {
if requiredExtensions == nil {
return nil, trace.BadParameter("requested challenge extensions must be supplied.")
}

if webauthnResponse == nil {
return nil, trace.BadParameter("webauthn response must be supplied")
}

const notFoundErrMsg = "browser MFA session data not found"
mfaSess, err := a.GetMFASessionData(ctx, sessionID)
if trace.IsNotFound(err) {
return nil, trace.NotFound("%s", notFoundErrMsg)
} else if err != nil {
return nil, trace.Wrap(err)
}

// Verify the user's name matches.
if mfaSess.Username != username {
return nil, trace.NotFound("%s", notFoundErrMsg)
}
Comment thread
danielashare marked this conversation as resolved.

// Verify this is a Browser MFA session and not an SSO MFA session.
if mfaSess.TSHRedirectURL == "" || mfaSess.ConnectorType != constants.BrowserMFA {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add this same check in VerifySSOMFASession?

a.logger.WarnContext(ctx,
"The Browser MFA flow was used to access a SSO MFA session.",
"request_id", mfaSess.RequestID,
"connector_type", mfaSess.ConnectorType,
"username", username,
)
return nil, trace.NotFound("%s", notFoundErrMsg)
}

// Check if the MFA session matches the user's Browser MFA settings.
devs, err := a.Services.GetMFADevices(ctx, username, false /* withSecrets */)
if err != nil {
return nil, trace.Wrap(err)
}

// Check the user has a Browser MFA device
groupedDevs := groupByDeviceType(devs)
if groupedDevs.Browser == nil {
if len(groupedDevs.Webauthn) == 0 {
a.logger.DebugContext(ctx,
"Browser MFA not available: user has no WebAuthn devices registered",
"user", username,
)
}
return nil, trace.AccessDenied("browser MFA not available")
}

// Check if the given scope is satisfied by the challenge scope.
if requiredExtensions.Scope != mfaSess.ChallengeExtensions.Scope {
return nil, trace.AccessDenied(
"required scope %q is not satisfied by the given browser MFA session with scope %q",
requiredExtensions.Scope,
mfaSess.ChallengeExtensions.Scope,
)
}

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

// Convert from protobuf type to wantypes
wanResp := wantypes.CredentialAssertionResponseFromProto(webauthnResponse)

cap, err := a.GetAuthPreference(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
waConfig, err := cap.GetWebauthn()
if err != nil {
return nil, trace.Wrap(err)
}
u2f, err := cap.GetU2F()
if err != nil && !trace.IsNotFound(err) {
return nil, trace.Wrap(err)
}

loginFlow := &wanlib.LoginFlow{
Webauthn: waConfig,
U2F: u2f,
Identity: a.Services,
Comment thread
danielashare marked this conversation as resolved.
}

// Verify webauthn response
loginData, err := loginFlow.Finish(ctx, username, wanResp, &mfav1.ChallengeExtensions{
Scope: mfaSess.ChallengeExtensions.Scope,
AllowReuse: mfaSess.ChallengeExtensions.AllowReuse,
})
Comment thread
danielashare marked this conversation as resolved.
Comment thread
danielashare marked this conversation as resolved.
Comment thread
danielashare marked this conversation as resolved.
Comment thread
danielashare marked this conversation as resolved.
if err != nil {
return nil, trace.AccessDenied("failed to verify WebAuthn response: %v", err)
}

if mfaSess.ChallengeExtensions.AllowReuse != mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_YES {
if err := a.DeleteMFASessionData(ctx, sessionID); err != nil {
return nil, trace.Wrap(err)
}
}

return &authz.MFAAuthData{
Device: loginData.Device,
User: username,
AllowReuse: mfaSess.ChallengeExtensions.AllowReuse,
Payload: mfaSess.Payload,
SourceCluster: mfaSess.SourceCluster,
TargetCluster: mfaSess.TargetCluster,
MFAViaBrowser: true,
}, nil
}
Loading
Loading