Skip to content

Commit

Permalink
Add SSO MFA ceremony support to WebUI per-session MFA.
Browse files Browse the repository at this point in the history
  • Loading branch information
Joerger committed Oct 23, 2024
1 parent cd3072f commit bc5f610
Show file tree
Hide file tree
Showing 16 changed files with 110 additions and 55 deletions.
5 changes: 5 additions & 0 deletions lib/auth/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import (
"github.com/gravitational/teleport/api/utils/keys"
"github.com/gravitational/teleport/lib/auth/authclient"
"github.com/gravitational/teleport/lib/authz"
"github.com/gravitational/teleport/lib/client/sso"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/loginrule"
Expand Down Expand Up @@ -978,6 +979,10 @@ func ValidateClientRedirect(clientRedirect string, ssoTestFlow bool, settings *t
// they're used a lot in test code
return nil
}
if clientRedirect == sso.WebMFARedirect {
// If this is a SSO redirect in the WebUI, allow.
return nil
}
u, err := url.Parse(clientRedirect)
if err != nil {
return trace.Wrap(err, "parsing client redirect URL")
Expand Down
6 changes: 3 additions & 3 deletions lib/client/sso/ceremony.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,14 @@ func NewCLICeremony(rd *Redirector, init CeremonyInit) *Ceremony {

// Ceremony is a customizable SSO MFA ceremony.
type MFACeremony struct {
clientCallbackURL string
ClientCallbackURL string
HandleRedirect func(ctx context.Context, redirectURL string) error
GetCallbackMFAToken func(ctx context.Context) (string, error)
}

// GetClientCallbackURL returns the client callback URL.
func (m *MFACeremony) GetClientCallbackURL() string {
return m.clientCallbackURL
return m.ClientCallbackURL
}

// Run the SSO MFA ceremony.
Expand All @@ -99,7 +99,7 @@ func (m *MFACeremony) Run(ctx context.Context, chal *proto.MFAAuthenticateChalle
// NewCLIMFACeremony creates a new CLI SSO ceremony from the given redirector.
func NewCLIMFACeremony(rd *Redirector) *MFACeremony {
return &MFACeremony{
clientCallbackURL: rd.ClientCallbackURL,
ClientCallbackURL: rd.ClientCallbackURL,
HandleRedirect: rd.OpenRedirect,
GetCallbackMFAToken: func(ctx context.Context) (string, error) {
loginResp, err := rd.WaitForResponse(ctx)
Expand Down
5 changes: 5 additions & 0 deletions lib/client/sso/redirector.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ const (

// DefaultLoginURL is the default login page.
DefaultLoginURL = "/web/login"

// WebMFARedirect is the landing page for SSO MFA in the WebUI. The WebUI set up a listener
// on this page in order to capture the SSO MFA response regardless of what page the challenge
// was requested from.
WebMFARedirect = "/web/sso_confirm"
)

// RedirectorConfig is configuration for an sso redirector.
Expand Down
4 changes: 4 additions & 0 deletions lib/client/weblogin.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ type MFAChallengeResponse struct {
TOTPCode string `json:"totp_code,omitempty"`
// WebauthnResponse is a response from a webauthn device.
WebauthnResponse *wantypes.CredentialAssertionResponse `json:"webauthn_response,omitempty"`
// SSOResponse is a response from an SSO MFA flow.
SSOResponse *proto.SSOResponse `json:"sso_response,omitempty"`
}

// GetOptionalMFAResponseProtoReq converts response to a type proto.MFAAuthenticateResponse,
Expand Down Expand Up @@ -457,6 +459,8 @@ type MFAAuthenticateChallenge struct {
WebauthnChallenge *wantypes.CredentialAssertion `json:"webauthn_challenge"`
// TOTPChallenge specifies whether TOTP is supported for this user.
TOTPChallenge bool `json:"totp_challenge"`
// SSOChallenge is an SSO MFA challenge.
SSOChallenge *proto.SSOChallenge `json:"sso_challenge"`
}

// MFARegisterChallenge is an MFA register challenge sent on new MFA register.
Expand Down
4 changes: 2 additions & 2 deletions lib/defaults/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -707,8 +707,8 @@ const (
// made for an existing file transfer request
WebsocketFileTransferDecision = "t"

// WebsocketWebauthnChallenge is sending a webauthn challenge.
WebsocketWebauthnChallenge = "n"
// MFAChallenge is sending an MFA challenge. Only supports WebAuthn and SSO MFA.
MFAChallenge = "n"

// WebsocketSessionMetadata is sending the data for a ssh session.
WebsocketSessionMetadata = "s"
Expand Down
8 changes: 4 additions & 4 deletions lib/srv/desktop/tdp/proto.go
Original file line number Diff line number Diff line change
Expand Up @@ -737,10 +737,10 @@ func DecodeMFA(in byteReader) (*MFA, error) {
}
s := string(mt)
switch s {
case defaults.WebsocketWebauthnChallenge:
case defaults.MFAChallenge:
default:
return nil, trace.BadParameter(
"got mfa type %v, expected %v (WebAuthn)", mt, defaults.WebsocketWebauthnChallenge)
"got mfa type %v, expected %v (WebAuthn)", mt, defaults.MFAChallenge)
}

var length uint32
Expand Down Expand Up @@ -780,10 +780,10 @@ func DecodeMFAChallenge(in byteReader) (*MFA, error) {
}
s := string(mt)
switch s {
case defaults.WebsocketWebauthnChallenge:
case defaults.MFAChallenge:
default:
return nil, trace.BadParameter(
"got mfa type %v, expected %v (WebAuthn)", mt, defaults.WebsocketWebauthnChallenge)
"got mfa type %v, expected %v (WebAuthn)", mt, defaults.MFAChallenge)
}

var length uint32
Expand Down
4 changes: 2 additions & 2 deletions lib/srv/desktop/tdp/proto_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ func TestMFA(t *testing.T) {
c := NewConn(&fakeConn{Buffer: &buff})

mfaWant := &MFA{
Type: defaults.WebsocketWebauthnChallenge[0],
Type: defaults.MFAChallenge[0],
MFAAuthenticateChallenge: &client.MFAAuthenticateChallenge{
WebauthnChallenge: &wantypes.CredentialAssertion{
Response: wantypes.PublicKeyCredentialRequestOptions{
Expand Down Expand Up @@ -159,7 +159,7 @@ func TestMFA(t *testing.T) {
require.Equal(t, mfaWant, mfaGot)

respWant := &MFA{
Type: defaults.WebsocketWebauthnChallenge[0],
Type: defaults.MFAChallenge[0],
MFAAuthenticateResponse: &authproto.MFAAuthenticateResponse{
Response: &authproto.MFAAuthenticateResponse_Webauthn{
Webauthn: &wanpb.CredentialAssertionResponse{
Expand Down
8 changes: 8 additions & 0 deletions lib/web/apiserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import (
"github.com/gravitational/teleport/lib/authz"
"github.com/gravitational/teleport/lib/automaticupgrades"
"github.com/gravitational/teleport/lib/client"
"github.com/gravitational/teleport/lib/client/sso"
"github.com/gravitational/teleport/lib/defaults"
dtconfig "github.com/gravitational/teleport/lib/devicetrust/config"
"github.com/gravitational/teleport/lib/events"
Expand Down Expand Up @@ -2207,6 +2208,13 @@ func ConstructSSHResponse(response AuthParams) (*url.URL, error) {
return nil, trace.Wrap(err)
}

// We don't use a secret key for WebUI SSO MFA redirects. The request ID itself is
// kept a secret on the front end to minimize the risk of a phishing attack.
if response.ClientRedirectURL == sso.WebMFARedirect && response.MFAToken != "" {
u.RawQuery = url.Values{"response": {string(out)}}.Encode()
return u, nil
}

// Extract secret out of the request.
secretKey := u.Query().Get("secret_key")
if secretKey == "" {
Expand Down
10 changes: 5 additions & 5 deletions lib/web/apiserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2181,7 +2181,7 @@ func TestTerminalRequireSessionMFA(t *testing.T) {

envelope := &terminal.Envelope{
Version: defaults.WebsocketVersion,
Type: defaults.WebsocketWebauthnChallenge,
Type: defaults.MFAChallenge,
Payload: string(webauthnResBytes),
}
protoBytes, err := proto.Marshal(envelope)
Expand Down Expand Up @@ -2388,7 +2388,7 @@ func handleDesktopMFAWebauthnChallenge(t *testing.T, ws *websocket.Conn, dev *au
})
require.NoError(t, err)
err = tdpConn.WriteMessage(tdp.MFA{
Type: defaults.WebsocketWebauthnChallenge[0],
Type: defaults.MFAChallenge[0],
MFAAuthenticateResponse: &authproto.MFAAuthenticateResponse{
Response: &authproto.MFAAuthenticateResponse_Webauthn{
Webauthn: res.GetWebauthn(),
Expand Down Expand Up @@ -10060,7 +10060,7 @@ func TestModeratedSessionWithMFA(t *testing.T) {

envelope := &terminal.Envelope{
Version: defaults.WebsocketVersion,
Type: defaults.WebsocketWebauthnChallenge,
Type: defaults.MFAChallenge,
Payload: string(webauthnResBytes),
}
envelopeBytes, err := proto.Marshal(envelope)
Expand Down Expand Up @@ -10091,7 +10091,7 @@ func TestModeratedSessionWithMFA(t *testing.T) {

envelope := &terminal.Envelope{
Version: defaults.WebsocketVersion,
Type: defaults.WebsocketWebauthnChallenge,
Type: defaults.MFAChallenge,
Payload: string(webauthnResBytes),
}
envelopeBytes, err := proto.Marshal(envelope)
Expand Down Expand Up @@ -10129,7 +10129,7 @@ func TestModeratedSessionWithMFA(t *testing.T) {

envelope := &terminal.Envelope{
Version: defaults.WebsocketVersion,
Type: defaults.WebsocketWebauthnChallenge,
Type: defaults.MFAChallenge,
Payload: string(webauthnResBytes),
}
envelopeBytes, err := proto.Marshal(envelope)
Expand Down
4 changes: 2 additions & 2 deletions lib/web/desktop.go
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ func (h *Handler) performSessionMFACeremony(
&client.MFAAuthenticateChallenge{
WebauthnChallenge: wantypes.CredentialAssertionFromProto(chal.WebauthnChallenge),
},
defaults.WebsocketWebauthnChallenge,
defaults.MFAChallenge,
)
if err != nil {
return nil, trace.Wrap(err)
Expand Down Expand Up @@ -421,7 +421,7 @@ func (h *Handler) performSessionMFACeremony(
break
}

assertion, err := codec.DecodeResponse(buf, defaults.WebsocketWebauthnChallenge)
assertion, err := codec.DecodeResponse(buf, defaults.MFAChallenge)
if err != nil {
return nil, trace.Wrap(err)
}
Expand Down
8 changes: 4 additions & 4 deletions lib/web/fuzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ func FuzzTdpMFACodecDecodeChallenge(f *testing.F) {
var normalBuf bytes.Buffer
var maxSizeBuf bytes.Buffer
// add initial bytes for protocol
_, err = normalBuf.Write([]byte{byte(tdp.TypeMFA), []byte(defaults.WebsocketWebauthnChallenge)[0]})
_, err = normalBuf.Write([]byte{byte(tdp.TypeMFA), []byte(defaults.MFAChallenge)[0]})
require.NoError(f, err)
_, err = maxSizeBuf.Write([]byte{byte(tdp.TypeMFA), []byte(defaults.WebsocketWebauthnChallenge)[0]})
_, err = maxSizeBuf.Write([]byte{byte(tdp.TypeMFA), []byte(defaults.MFAChallenge)[0]})
require.NoError(f, err)
// Write the length using BigEndian encoding
require.NoError(f, binary.Write(&normalBuf, binary.BigEndian, uint32(len(jsonData))))
Expand All @@ -84,9 +84,9 @@ func FuzzTdpMFACodecDecodeResponse(f *testing.F) {
var normalBuf bytes.Buffer
var maxSizeBuf bytes.Buffer
// add initial bytes for protocol
_, err := normalBuf.Write([]byte{byte(tdp.TypeMFA), []byte(defaults.WebsocketWebauthnChallenge)[0]})
_, err := normalBuf.Write([]byte{byte(tdp.TypeMFA), []byte(defaults.MFAChallenge)[0]})
require.NoError(f, err)
_, err = maxSizeBuf.Write([]byte{byte(tdp.TypeMFA), []byte(defaults.WebsocketWebauthnChallenge)[0]})
_, err = maxSizeBuf.Write([]byte{byte(tdp.TypeMFA), []byte(defaults.MFAChallenge)[0]})
require.NoError(f, err)
mfaData := []byte("fake-data")
// Write the length using BigEndian encoding
Expand Down
6 changes: 6 additions & 0 deletions lib/web/mfa.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1"
wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes"
"github.com/gravitational/teleport/lib/client"
"github.com/gravitational/teleport/lib/client/sso"
"github.com/gravitational/teleport/lib/httplib"
"github.com/gravitational/teleport/lib/reversetunnelclient"
"github.com/gravitational/teleport/lib/web/ui"
Expand Down Expand Up @@ -175,6 +176,7 @@ func (h *Handler) createAuthenticateChallengeHandle(w http.ResponseWriter, r *ht
AllowReuse: allowReuse,
UserVerificationRequirement: req.UserVerificationRequirement,
},
SSOClientRedirectURL: sso.WebMFARedirect,
})
if err != nil {
return nil, trace.Wrap(err)
Expand All @@ -192,6 +194,7 @@ func (h *Handler) createAuthenticateChallengeWithTokenHandle(w http.ResponseWrit
ChallengeExtensions: &mfav1.ChallengeExtensions{
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_ACCOUNT_RECOVERY,
},
SSOClientRedirectURL: sso.WebMFARedirect,
})
if err != nil {
return nil, trace.Wrap(err)
Expand Down Expand Up @@ -484,5 +487,8 @@ func makeAuthenticateChallenge(protoChal *proto.MFAAuthenticateChallenge) *clien
if protoChal.GetWebauthnChallenge() != nil {
chal.WebauthnChallenge = wantypes.CredentialAssertionFromProto(protoChal.WebauthnChallenge)
}
if protoChal.GetSSOChallenge() != nil {
chal.SSOChallenge = protoChal.GetSSOChallenge()
}
return chal
}
4 changes: 2 additions & 2 deletions lib/web/mfa_codec.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,10 @@ type tdpMFACodec struct{}

func (tdpMFACodec) Encode(chal *client.MFAAuthenticateChallenge, envelopeType string) ([]byte, error) {
switch envelopeType {
case defaults.WebsocketWebauthnChallenge:
case defaults.MFAChallenge:
default:
return nil, trace.BadParameter(
"received envelope type %v, expected %v (WebAuthn)", envelopeType, defaults.WebsocketWebauthnChallenge)
"received envelope type %v, expected %v (WebAuthn)", envelopeType, defaults.MFAChallenge)
}

tdpMsg := tdp.MFA{
Expand Down
52 changes: 36 additions & 16 deletions lib/web/mfajson/mfajson.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,30 +25,50 @@ import (

authproto "github.com/gravitational/teleport/api/client/proto"
wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/client"
)

// TODO(Joerger): DELETE IN v18.0.0 and use client.MFAChallengeResponse instead.
// Before v17, the WebUI sends a flattened webauthn response instead of a full
// MFA challenge response. Newer WebUI versions v17+ will send both for
// backwards compatibility.
type challengeResponse struct {
client.MFAChallengeResponse
*wantypes.CredentialAssertionResponse
}

// Decode parses a JSON-encoded MFA authentication response.
// Only webauthn (type="n") is currently supported.
func Decode(b []byte, typ string) (*authproto.MFAAuthenticateResponse, error) {
var resp *authproto.MFAAuthenticateResponse
var resp challengeResponse
if err := json.Unmarshal(b, &resp); err != nil {
return nil, trace.Wrap(err)
}

switch typ {
case defaults.WebsocketWebauthnChallenge:
var webauthnResponse wantypes.CredentialAssertionResponse
if err := json.Unmarshal(b, &webauthnResponse); err != nil {
return nil, trace.Wrap(err)
}
resp = &authproto.MFAAuthenticateResponse{
switch {
case resp.CredentialAssertionResponse != nil:
return &authproto.MFAAuthenticateResponse{
Response: &authproto.MFAAuthenticateResponse_Webauthn{
Webauthn: wantypes.CredentialAssertionResponseToProto(&webauthnResponse),
Webauthn: wantypes.CredentialAssertionResponseToProto(resp.WebauthnResponse),
},
}

}, nil
case resp.WebauthnResponse != nil:
return &authproto.MFAAuthenticateResponse{
Response: &authproto.MFAAuthenticateResponse_Webauthn{
Webauthn: wantypes.CredentialAssertionResponseToProto(resp.WebauthnResponse),
},
}, nil
case resp.SSOResponse != nil:
return &authproto.MFAAuthenticateResponse{
Response: &authproto.MFAAuthenticateResponse_SSO{
SSO: resp.SSOResponse,
},
}, nil
case resp.TOTPCode != "":
// Note: we can support TOTP through the websocket if desired, we just need to add
// a TOTP prompt modal and flip the switch here.
return nil, trace.BadParameter("totp is not supported in the WebUI")
default:
return nil, trace.BadParameter(
"received type %v, expected %v (WebAuthn)", typ, defaults.WebsocketWebauthnChallenge)
return nil, trace.BadParameter("invalid MFA response from web")
}

return resp, nil
}
Loading

0 comments on commit bc5f610

Please sign in to comment.