Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: SSO MFA - Add SSOMFASessionData #47647

Merged
merged 6 commits into from
Oct 21, 2024
Merged
Show file tree
Hide file tree
Changes from 5 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
49 changes: 49 additions & 0 deletions lib/auth/sso_mfa.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package auth

import (
"context"

"github.com/gravitational/trace"

mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/utils"
)

// UpsertSSOMFASession upserts a new unverified SSO MFA session for the given username,
// sessionID, connector details, and challenge extensions.
func (a *Server) UpsertSSOMFASession(ctx context.Context, user string, sessionID string, connectorID string, connectorType string, ext *mfav1.ChallengeExtensions) error {
err := a.UpsertSSOMFASessionData(ctx, &services.SSOMFASessionData{
Username: user,
RequestID: sessionID,
ConnectorID: connectorID,
ConnectorType: connectorType,
ChallengeExtensions: ext,
})
return trace.Wrap(err)
}

// UpsertSSOMFASessionWithToken upserts the given SSO MFA session with a random mfa token.
func (a *Server) UpsertSSOMFASessionWithToken(ctx context.Context, sd *services.SSOMFASessionData) (token string, err error) {
sd.Token, err = utils.CryptoRandomHex(defaults.TokenLenBytes)
if err != nil {
return "", trace.Wrap(err)
}

if err := a.UpsertSSOMFASessionData(ctx, sd); err != nil {
return "", trace.Wrap(err)
}

return sd.Token, nil
}

// GetSSOMFASession returns the SSO MFA session for the given username and sessionID.
func (a *Server) GetSSOMFASession(ctx context.Context, sessionID string) (*services.SSOMFASessionData, error) {
sd, err := a.GetSSOMFASessionData(ctx, sessionID)
if err != nil {
return nil, trace.Wrap(err)
}

return sd, nil
}
11 changes: 11 additions & 0 deletions lib/services/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,17 @@ type Identity interface {
// GetGithubAuthRequest retrieves Github auth request by the token
GetGithubAuthRequest(ctx context.Context, stateToken string) (*types.GithubAuthRequest, error)

// UpsertSSOMFASessionData creates or updates SSO MFA session data in
// storage, for the purpose of later verifying an MFA authentication attempt.
// SSO MFA session data is expected to expire according to backend settings.
UpsertSSOMFASessionData(ctx context.Context, sd *SSOMFASessionData) error

// GetSSOMFASessionData retrieves SSO MFA session data by ID.
GetSSOMFASessionData(ctx context.Context, sessionID string) (*SSOMFASessionData, error)

// DeleteSSOMFASessionData deletes SSO MFA session data by ID.
DeleteSSOMFASessionData(ctx context.Context, sessionID string) error

// CreateUserToken creates a new user token.
CreateUserToken(ctx context.Context, token types.UserToken) (types.UserToken, error)

Expand Down
52 changes: 52 additions & 0 deletions lib/services/local/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -1884,6 +1884,57 @@ func (s *IdentityService) GetSSODiagnosticInfo(ctx context.Context, authKind str
return &req, nil
}

func (s *IdentityService) UpsertSSOMFASessionData(ctx context.Context, sd *services.SSOMFASessionData) error {
switch {
case sd == nil:
return trace.BadParameter("missing parameter sd")
case sd.RequestID == "":
return trace.BadParameter("missing parameter RequestID")
case sd.ConnectorID == "":
return trace.BadParameter("missing parameter ConnectorID")
case sd.ConnectorType == "":
return trace.BadParameter("missing parameter ConnectorType")
case sd.Username == "":
return trace.BadParameter("missing parameter Username")
}

value, err := json.Marshal(sd)
Copy link
Contributor

@espadolini espadolini Oct 17, 2024

Choose a reason for hiding this comment

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

If this is a brand new type I'd strongly recommend protojson, FWIW.

edit: nevermind, it's specifically defined as JSON

Copy link
Contributor

Choose a reason for hiding this comment

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

The struct contains a protobuf-defined type; is that currently (wrongly) marshaled and unmarshaled with encoding/json?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I suppose so, what issues does this cause? We are already json marshalling *mfav1.ChallengeExtensions in the webauthn session data as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Do you think a custom marshaller on services.SSOMFASessionData to protojson.Marshal the challenge extensions is warranted?

Copy link
Contributor

Choose a reason for hiding this comment

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

The encoding/json encoding of a protobuf message is not canonical in any way, which means that Go ends up being the only thing that knows what the shape of the json data is supposed to be. Perhaps you could define SSOMFASessionData as a protobuf message even if it's not directly used through grpc? That way you also get breaking change detection as part of the linting, and in a future where we generate protobuf types for javascript you'll get the same type definition on that side as well, without having to manually sync it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This session data never leaves the Auth server, so whether or not it's supported across a transport layer doesn't really matter.

Furthermore, I don't see exactly why it would be difficult to maintain the same JSON structure across different languages, but if this is the case and we changed our Auth/Proxy servers to non-go, we'd have a lot of issues with proto fields in our web api json requests and responses. e.g:

type getLoginAlertsResponse struct {
	Alerts []types.ClusterAlert `json:"alerts"`
}

I don't think we have any plan to have a different language implementation of Teleport, so I don't think this is a problem we need to concern ourselves with anyways, right?

Also, this is just a short lived resource, so we could easily change it in the future without any backwards compatibility concerns. I'll merge this as is as we can always make a follow up PR to change it.

Copy link
Contributor

Choose a reason for hiding this comment

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

It's a resource that gets marshalled and unmarshalled, and it gets stored in a backend. What even is the forward compatible breaking change path for something like that? How do you change it without it resulting in massive breakages?

The fact that we are relying on the implicit data shape of protobuf messages to use encoding/json is already very much a big problem, one that for example forces us to keep using the gogoproto generator and library even though we should've stopped using it a long time ago, and each new thing that we marshal like this makes it harder and harder to keep track of what the actual schema of our data is.

I don't think we have any plan to have a different language implementation of Teleport, so I don't think this is a problem we need to concern ourselves with anyways, right?

We literally face this problem when we aspire to use protobuf in our frontend and are faced with the reality that the user-facing data shape for various things (which matches the data shape stored in our backend) is something vaguely deterministic that's only defined by how a specific code generator for Go decided to arrange things rather than anything canonical - while also dealing with all the drawbacks of a generated data type.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's a resource that gets marshalled and unmarshalled, and it gets stored in a backend. What even is the forward compatible breaking change path for something like that? How do you change it without it resulting in massive breakages?

The resource only exists for 5 minutes, or shorter if the user consumes it. I'm not saying it's a perfectly forward compatible change, but worst case there would be a small number of failed MFA attempts when the Auth server upgrades, and it would sort itself out momentarily. Any user with the failed attempt would be able to succeed on their next attempt. A changelog note would be enough.

Copy link
Contributor

Choose a reason for hiding this comment

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

If that's the only possible failure scenario then sure, it's not a problem - what happens if the data is misinterpreted because a new protobuf codegen has changed something in how the struct is laid out and we didn't notice, tho?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Then the value should be treated as the zero value, so it would be treated like a challenge with an unspecified scope with reuse not allowed, which is the least privileged type of challenge and would get rejected in basically any context. Anyways, I'll make a follow up PR.

if err != nil {
return trace.Wrap(err)
}
_, err = s.Put(ctx, backend.Item{
Key: ssoMFASessionDataKey(sd.RequestID),
Value: value,
Expires: s.Clock().Now().UTC().Add(defaults.WebauthnChallengeTimeout),
})
return trace.Wrap(err)
}

func (s *IdentityService) GetSSOMFASessionData(ctx context.Context, sessionID string) (*services.SSOMFASessionData, error) {
if sessionID == "" {
return nil, trace.BadParameter("missing parameter sessionID")
}

item, err := s.Get(ctx, ssoMFASessionDataKey(sessionID))
if err != nil {
return nil, trace.Wrap(err)
}
sd := &services.SSOMFASessionData{}
return sd, trace.Wrap(json.Unmarshal(item.Value, sd))
}

func (s *IdentityService) DeleteSSOMFASessionData(ctx context.Context, sessionID string) error {
if sessionID == "" {
return trace.BadParameter("missing parameter sessionID")
}

return trace.Wrap(s.Delete(ctx, ssoMFASessionDataKey(sessionID)))
}

func ssoMFASessionDataKey(sessionID string) backend.Key {
return backend.NewKey(webPrefix, ssoMFASessionData, sessionID)
}

// UpsertGithubConnector creates or updates a Github connector
func (s *IdentityService) UpsertGithubConnector(ctx context.Context, connector types.GithubConnector) (types.GithubConnector, error) {
if err := services.CheckAndSetDefaults(connector); err != nil {
Expand Down Expand Up @@ -2164,6 +2215,7 @@ const (
webauthnGlobalSessionData = "sessionData"
webauthnLocalAuthPrefix = "webauthnlocalauth"
webauthnSessionData = "webauthnsessiondata"
ssoMFASessionData = "ssomfasessiondata"
recoveryCodesPrefix = "recoverycodes"
attestationsPrefix = "key_attestations"
userPreferencesPrefix = "user_preferences"
Expand Down
39 changes: 39 additions & 0 deletions lib/services/local/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1741,3 +1741,42 @@ func TestWeakestMFADeviceKind(t *testing.T) {
require.NoError(t, err)
require.Equal(t, types.MFADeviceKind_MFA_DEVICE_KIND_TOTP, got.GetWeakestDevice())
}

func TestIdentityService_SSOMFASessionDataCRUD(t *testing.T) {
t.Parallel()
ctx := context.Background()
identity := newIdentityService(t, clockwork.NewFakeClock())

// Verify create.
sd := &services.SSOMFASessionData{
RequestID: "request",
Username: "alice",
ConnectorID: "saml",
ConnectorType: "saml",
}
err := identity.UpsertSSOMFASessionData(ctx, sd)
require.NoError(t, err)

// Verify read.
got, err := identity.GetSSOMFASessionData(ctx, sd.RequestID)
require.NoError(t, err)
if diff := cmp.Diff(sd, got); diff != "" {
t.Fatalf("GetSSOMFASessionData() mismatch (-want +got):\n%s", diff)
}

// Verify update.
sd.Token = "token"
err = identity.UpsertSSOMFASessionData(ctx, sd)
require.NoError(t, err)
got, err = identity.GetSSOMFASessionData(ctx, sd.RequestID)
require.NoError(t, err)
if diff := cmp.Diff(sd, got); diff != "" {
t.Fatalf("GetSSOMFASessionData() mismatch (-want +got):\n%s", diff)
}

// Verify delete.
err = identity.DeleteSSOMFASessionData(ctx, sd.RequestID)
require.NoError(t, err)
_, err = identity.GetSSOMFASessionData(ctx, sd.RequestID)
require.True(t, trace.IsNotFound(err))
}
38 changes: 38 additions & 0 deletions lib/services/sso_mfa.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Teleport
* Copyright (C) 2024 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package services

import mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1"

// SSOMFASessionData SSO MFA Session data.
type SSOMFASessionData struct {
// RequestID is the ID of the corresponding SSO Auth request, which is used to
// identity this session.
RequestID string `json:"request_id,omitempty"`
// Username is the Teleport username.
Username string `json:"username,omitempty"`
// Token is an active token used to verify the owner of this SSO MFA session data.
Token string `json:"token,omitempty"`
// ConnectorID is id of the corresponding Auth connector.
ConnectorID string `json:"connector_id,omitempty"`
// ConnectorType is SSO type of the corresponding Auth connector (SAML, OIDC).
ConnectorType string `json:"connector_type,omitempty"`
// ChallengeExtensions are Teleport extensions that apply to this SSO MFA session.
ChallengeExtensions *mfav1.ChallengeExtensions `json:"challenge_extensions,omitempty"`
}
Loading