mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-24 16:17:11 +08:00
feat: SSO MFA - SSO MFA challenge creation and verification (#47684)
* Update e ref. * Update proto. * Handle SSO MFA challenge creation. * Handle SSO MFA challenge response validation. * Only create SSO MFA challenge if SSO MFA is an enabled second factor; Require SSO MFA for device registration when available. * Update verification logic; Add test. * Fix lint.
This commit is contained in:
+1783
-1109
File diff suppressed because it is too large
Load Diff
@@ -1215,6 +1215,10 @@ message MFAAuthenticateChallenge {
|
||||
// communications, in case of streaming RPCs. It may also return empty
|
||||
// challenges for all other fields.
|
||||
MFARequired MFARequired = 4;
|
||||
// SSO Challenge is an SSO MFA challenge. If set, the client can go to the
|
||||
// IdP redirect URL to perform an MFA check in the IdP and obtain an MFA token.
|
||||
// This token paired with the request id can then be used as MFA verification.
|
||||
SSOChallenge SSOChallenge = 5;
|
||||
}
|
||||
|
||||
// MFAAuthenticateResponse is a response to MFAAuthenticateChallenge using one
|
||||
@@ -1224,6 +1228,7 @@ message MFAAuthenticateResponse {
|
||||
// Removed: U2FResponse U2F = 1;
|
||||
TOTPResponse TOTP = 2;
|
||||
webauthn.CredentialAssertionResponse Webauthn = 3;
|
||||
SSOResponse SSO = 4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1240,6 +1245,22 @@ message TOTPResponse {
|
||||
string Code = 1;
|
||||
}
|
||||
|
||||
// SSOChallenge contains SSO auth request details to perform an SSO MFA check.
|
||||
message SSOChallenge {
|
||||
// RequestId is the ID of an SSO auth request.
|
||||
string request_id = 1;
|
||||
// RedirectUrl is an IdP redirect URL to initate the SSO MFA flow.
|
||||
string redirect_url = 2;
|
||||
}
|
||||
|
||||
// SSOResponse is a response to SSOChallenge.
|
||||
message SSOResponse {
|
||||
// RequestId is the ID of an SSO auth request.
|
||||
string request_id = 1;
|
||||
// Token is a secret token used to verify the user's SSO MFA session.
|
||||
string token = 2;
|
||||
}
|
||||
|
||||
// MFARegisterChallenge is a challenge for registering a new MFA device.
|
||||
message MFARegisterChallenge {
|
||||
// Request depends on the type of the MFA device being registered.
|
||||
@@ -1864,9 +1885,11 @@ message CreateAuthenticateChallengeRequest {
|
||||
// call [AuthService.IsMFARequired] in the leaf instead of setting this field.
|
||||
IsMFARequiredRequest MFARequiredCheck = 5 [(gogoproto.jsontag) = "mfa_required_check,omitempty"];
|
||||
// ChallengeExtensions are extensions that will be apply to the issued MFA challenge.
|
||||
// ChallengeExtensions only apply to webauthn challenges currently. Required, except
|
||||
// for v15 clients and older.
|
||||
// Required, except for v15 clients and older.
|
||||
teleport.mfa.v1.ChallengeExtensions ChallengeExtensions = 6 [(gogoproto.jsontag) = "challenge_extensions,omitempty"];
|
||||
// SSOClientRedirectURL should be supplied If the client supports SSO MFA checks.
|
||||
// If unset, the server will only return non-SSO challenges.
|
||||
string SSOClientRedirectURL = 7 [(gogoproto.jsontag) = "sso_client_redirect_url,omitempty"];
|
||||
}
|
||||
|
||||
// CreatePrivilegeTokenRequest defines a request to obtain a privilege token.
|
||||
|
||||
+1
-1
Submodule e updated: b5db15651a...88c5aecb07
+30
-16
@@ -3673,7 +3673,7 @@ func (a *Server) CreateAuthenticateChallenge(ctx context.Context, req *proto.Cre
|
||||
}
|
||||
}
|
||||
|
||||
challenges, err := a.mfaAuthChallenge(ctx, username, challengeExtensions)
|
||||
challenges, err := a.mfaAuthChallenge(ctx, username, req.SSOClientRedirectURL, challengeExtensions)
|
||||
if err != nil {
|
||||
// Do not obfuscate config-related errors.
|
||||
if errors.Is(err, types.ErrPasswordlessRequiresWebauthn) || errors.Is(err, types.ErrPasswordlessDisabledBySettings) {
|
||||
@@ -6754,7 +6754,7 @@ func (a *Server) isMFARequired(ctx context.Context, checker services.AccessCheck
|
||||
|
||||
// mfaAuthChallenge constructs an MFAAuthenticateChallenge for all MFA devices
|
||||
// registered by the user.
|
||||
func (a *Server) mfaAuthChallenge(ctx context.Context, user string, challengeExtensions *mfav1.ChallengeExtensions) (*proto.MFAAuthenticateChallenge, error) {
|
||||
func (a *Server) mfaAuthChallenge(ctx context.Context, user string, ssoClientRedirectURL string, challengeExtensions *mfav1.ChallengeExtensions) (*proto.MFAAuthenticateChallenge, error) {
|
||||
isPasswordless := challengeExtensions.Scope == mfav1.ChallengeScope_CHALLENGE_SCOPE_PASSWORDLESS_LOGIN
|
||||
|
||||
// Check what kind of MFA is enabled.
|
||||
@@ -6764,6 +6764,7 @@ func (a *Server) mfaAuthChallenge(ctx context.Context, user string, challengeExt
|
||||
}
|
||||
enableTOTP := apref.IsSecondFactorTOTPAllowed()
|
||||
enableWebauthn := apref.IsSecondFactorWebauthnAllowed()
|
||||
enableSSO := apref.IsSecondFactorSSOAllowed()
|
||||
|
||||
// Fetch configurations. The IsSecondFactor*Allowed calls above already
|
||||
// include the necessary checks of config empty, disabled, etc.
|
||||
@@ -6834,7 +6835,7 @@ func (a *Server) mfaAuthChallenge(ctx context.Context, user string, challengeExt
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
groupedDevs := groupByDeviceType(devs, enableWebauthn)
|
||||
groupedDevs := groupByDeviceType(devs)
|
||||
challenge := &proto.MFAAuthenticateChallenge{}
|
||||
|
||||
// TOTP challenge.
|
||||
@@ -6843,7 +6844,7 @@ func (a *Server) mfaAuthChallenge(ctx context.Context, user string, challengeExt
|
||||
}
|
||||
|
||||
// WebAuthn challenge.
|
||||
if len(groupedDevs.Webauthn) > 0 {
|
||||
if enableWebauthn && len(groupedDevs.Webauthn) > 0 {
|
||||
webLogin := &wanlib.LoginFlow{
|
||||
U2F: u2fPref,
|
||||
Webauthn: webConfig,
|
||||
@@ -6856,6 +6857,14 @@ func (a *Server) mfaAuthChallenge(ctx context.Context, user string, challengeExt
|
||||
challenge.WebauthnChallenge = wantypes.CredentialAssertionToProto(assertion)
|
||||
}
|
||||
|
||||
// If the user has an SSO device and the client provided a redirect URL to handle
|
||||
// the MFA SSO flow, create an SSO challenge.
|
||||
if enableSSO && groupedDevs.SSO != nil && ssoClientRedirectURL != "" {
|
||||
if challenge.SSOChallenge, err = a.beginSSOMFAChallenge(ctx, user, groupedDevs.SSO.GetSso(), ssoClientRedirectURL, challengeExtensions); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
clusterName, err := a.GetClusterName()
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
@@ -6883,20 +6892,16 @@ type devicesByType struct {
|
||||
SSO *types.MFADevice
|
||||
}
|
||||
|
||||
func groupByDeviceType(devs []*types.MFADevice, groupWebauthn bool) devicesByType {
|
||||
func groupByDeviceType(devs []*types.MFADevice) devicesByType {
|
||||
res := devicesByType{}
|
||||
for _, dev := range devs {
|
||||
switch dev.Device.(type) {
|
||||
case *types.MFADevice_Totp:
|
||||
res.TOTP = true
|
||||
case *types.MFADevice_U2F:
|
||||
if groupWebauthn {
|
||||
res.Webauthn = append(res.Webauthn, dev)
|
||||
}
|
||||
res.Webauthn = append(res.Webauthn, dev)
|
||||
case *types.MFADevice_Webauthn:
|
||||
if groupWebauthn {
|
||||
res.Webauthn = append(res.Webauthn, dev)
|
||||
}
|
||||
res.Webauthn = append(res.Webauthn, dev)
|
||||
case *types.MFADevice_Sso:
|
||||
res.SSO = dev
|
||||
default:
|
||||
@@ -6914,7 +6919,7 @@ func groupByDeviceType(devs []*types.MFADevice, groupWebauthn bool) devicesByTyp
|
||||
// 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) {
|
||||
if resp == nil || (resp.GetTOTP() == nil && resp.GetWebauthn() == nil && resp.GetSSO() == nil) {
|
||||
devices, err := a.Services.GetMFADevices(ctx, username, false /* withSecrets */)
|
||||
if err != nil {
|
||||
return false, trace.Wrap(err)
|
||||
@@ -6923,16 +6928,18 @@ func (a *Server) validateMFAAuthResponseForRegister(ctx context.Context, resp *p
|
||||
// Allowed, no devices registered.
|
||||
return false, nil
|
||||
}
|
||||
devsByType := groupByDeviceType(devices)
|
||||
|
||||
authPref, err := a.GetAuthPreference(ctx)
|
||||
if err != nil {
|
||||
return false, trace.Wrap(err)
|
||||
}
|
||||
totpEnabled := authPref.IsSecondFactorTOTPAllowed()
|
||||
webauthnEnabled := authPref.IsSecondFactorWebauthnAllowed()
|
||||
|
||||
devsByType := groupByDeviceType(devices, webauthnEnabled)
|
||||
if (totpEnabled && devsByType.TOTP) || (webauthnEnabled && len(devsByType.Webauthn) > 0) {
|
||||
hasTOTP := authPref.IsSecondFactorTOTPAllowed() && devsByType.TOTP
|
||||
hasWebAuthn := authPref.IsSecondFactorWebauthnAllowed() && len(devsByType.Webauthn) > 0
|
||||
hasSSO := authPref.IsSecondFactorSSOAllowed() && devsByType.SSO != nil
|
||||
|
||||
if hasTOTP || hasWebAuthn || hasSSO {
|
||||
return false, trace.BadParameter("second factor authentication required")
|
||||
}
|
||||
|
||||
@@ -6960,6 +6967,10 @@ func (a *Server) ValidateMFAAuthResponse(
|
||||
user string,
|
||||
requiredExtensions *mfav1.ChallengeExtensions,
|
||||
) (*authz.MFAAuthData, error) {
|
||||
if requiredExtensions == nil {
|
||||
return nil, trace.BadParameter("required challenge extensions parameter required")
|
||||
}
|
||||
|
||||
authData, validateErr := a.validateMFAAuthResponseInternal(ctx, resp, user, requiredExtensions)
|
||||
// validateErr handled after audit.
|
||||
|
||||
@@ -7094,6 +7105,9 @@ func (a *Server) validateMFAAuthResponseInternal(
|
||||
AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_NO,
|
||||
}, nil
|
||||
|
||||
case *proto.MFAAuthenticateResponse_SSO:
|
||||
mfaAuthData, err := a.verifySSOMFASession(ctx, user, res.SSO.RequestId, res.SSO.Token, requiredExtensions)
|
||||
return mfaAuthData, trace.Wrap(err)
|
||||
default:
|
||||
return nil, trace.BadParameter("unknown or missing MFAAuthenticateResponse type %T", resp.Response)
|
||||
}
|
||||
|
||||
@@ -2165,8 +2165,11 @@ func (g *GRPCServer) DeleteRole(ctx context.Context, req *authpb.DeleteRoleReque
|
||||
func doMFAPresenceChallenge(ctx context.Context, actx *grpcContext, stream authpb.AuthService_MaintainSessionPresenceServer, challengeReq *authpb.PresenceMFAChallengeRequest) error {
|
||||
user := actx.User.GetName()
|
||||
|
||||
// TODO(Joerger): Extend SSO MFA support for moderated sessions.
|
||||
var ssoClientRedirectURL string
|
||||
|
||||
chalExt := &mfav1pb.ChallengeExtensions{Scope: mfav1pb.ChallengeScope_CHALLENGE_SCOPE_USER_SESSION}
|
||||
authChallenge, err := actx.authServer.mfaAuthChallenge(ctx, user, chalExt)
|
||||
authChallenge, err := actx.authServer.mfaAuthChallenge(ctx, user, ssoClientRedirectURL, chalExt)
|
||||
if err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
|
||||
type OIDCService interface {
|
||||
CreateOIDCAuthRequest(ctx context.Context, req types.OIDCAuthRequest) (*types.OIDCAuthRequest, error)
|
||||
CreateOIDCAuthRequestForMFA(ctx context.Context, req types.OIDCAuthRequest) (*types.OIDCAuthRequest, error)
|
||||
ValidateOIDCAuthCallback(ctx context.Context, q url.Values) (*authclient.OIDCAuthResponse, error)
|
||||
}
|
||||
|
||||
@@ -124,6 +125,8 @@ func (a *Server) DeleteOIDCConnector(ctx context.Context, connectorName string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateOIDCAuthRequest delegates the method call to the oidcAuthService if present,
|
||||
// or returns a NotImplemented error if not present.
|
||||
func (a *Server) CreateOIDCAuthRequest(ctx context.Context, req types.OIDCAuthRequest) (*types.OIDCAuthRequest, error) {
|
||||
if a.oidcAuthService == nil {
|
||||
return nil, errOIDCNotImplemented
|
||||
@@ -133,6 +136,19 @@ func (a *Server) CreateOIDCAuthRequest(ctx context.Context, req types.OIDCAuthRe
|
||||
return rq, trace.Wrap(err)
|
||||
}
|
||||
|
||||
// CreateOIDCAuthRequestForMFA delegates the method call to the oidcAuthService if present,
|
||||
// or returns a NotImplemented error if not present.
|
||||
func (a *Server) CreateOIDCAuthRequestForMFA(ctx context.Context, req types.OIDCAuthRequest) (*types.OIDCAuthRequest, error) {
|
||||
if a.oidcAuthService == nil {
|
||||
return nil, errOIDCNotImplemented
|
||||
}
|
||||
|
||||
rq, err := a.oidcAuthService.CreateOIDCAuthRequestForMFA(ctx, req)
|
||||
return rq, trace.Wrap(err)
|
||||
}
|
||||
|
||||
// ValidateOIDCAuthCallback delegates the method call to the oidcAuthService if present,
|
||||
// or returns a NotImplemented error if not present.
|
||||
func (a *Server) ValidateOIDCAuthCallback(ctx context.Context, q url.Values) (*authclient.OIDCAuthResponse, error) {
|
||||
if a.oidcAuthService == nil {
|
||||
return nil, errOIDCNotImplemented
|
||||
|
||||
+12
-2
@@ -44,9 +44,8 @@ var ErrSAMLRequiresEnterprise = &trace.AccessDeniedError{Message: "SAML is only
|
||||
// authentication - the connector CRUD operations and Get methods are
|
||||
// implemented in auth.Server and provide no connector-specific logic.
|
||||
type SAMLService interface {
|
||||
// CreateSAMLAuthRequest creates SAML AuthnRequest
|
||||
CreateSAMLAuthRequest(ctx context.Context, req types.SAMLAuthRequest) (*types.SAMLAuthRequest, error)
|
||||
// ValidateSAMLResponse validates SAML auth response
|
||||
CreateSAMLAuthRequestForMFA(ctx context.Context, req types.SAMLAuthRequest) (*types.SAMLAuthRequest, error)
|
||||
ValidateSAMLResponse(ctx context.Context, samlResponse, connectorID, clientIP string) (*authclient.SAMLAuthResponse, error)
|
||||
}
|
||||
|
||||
@@ -214,6 +213,17 @@ func (a *Server) CreateSAMLAuthRequest(ctx context.Context, req types.SAMLAuthRe
|
||||
return rq, trace.Wrap(err)
|
||||
}
|
||||
|
||||
// CreateSAMLAuthRequestForMFA delegates the method call to the samlAuthService if present,
|
||||
// or returns a NotImplemented error if not present.
|
||||
func (a *Server) CreateSAMLAuthRequestForMFA(ctx context.Context, req types.SAMLAuthRequest) (*types.SAMLAuthRequest, error) {
|
||||
if a.samlAuthService == nil {
|
||||
return nil, trace.Wrap(ErrSAMLRequiresEnterprise)
|
||||
}
|
||||
|
||||
rq, err := a.samlAuthService.CreateSAMLAuthRequestForMFA(ctx, req)
|
||||
return rq, trace.Wrap(err)
|
||||
}
|
||||
|
||||
// ValidateSAMLResponse delegates the method call to the samlAuthService if present,
|
||||
// or returns a NotImplemented error if not present.
|
||||
func (a *Server) ValidateSAMLResponse(ctx context.Context, samlResponse, connectorID, clientIP string) (*authclient.SAMLAuthResponse, error) {
|
||||
|
||||
+108
-2
@@ -18,18 +18,124 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
|
||||
"github.com/gravitational/trace"
|
||||
|
||||
"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"
|
||||
"github.com/gravitational/teleport/api/types"
|
||||
"github.com/gravitational/teleport/lib/authz"
|
||||
"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,
|
||||
// beginSSOMFAChallenge creates a new SSO MFA auth request and session data for the given user and sso device.
|
||||
func (a *Server) beginSSOMFAChallenge(ctx context.Context, user string, sso *types.SSOMFADevice, ssoClientRedirectURL string, ext *mfav1.ChallengeExtensions) (*proto.SSOChallenge, error) {
|
||||
chal := new(proto.SSOChallenge)
|
||||
switch sso.ConnectorType {
|
||||
case constants.SAML:
|
||||
resp, err := a.CreateSAMLAuthRequestForMFA(ctx, types.SAMLAuthRequest{
|
||||
ConnectorID: sso.ConnectorId,
|
||||
Type: sso.ConnectorType,
|
||||
ClientRedirectURL: ssoClientRedirectURL,
|
||||
CheckUser: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
chal.RequestId = resp.ID
|
||||
chal.RedirectUrl = resp.RedirectURL
|
||||
case constants.OIDC:
|
||||
resp, err := a.CreateOIDCAuthRequestForMFA(ctx, types.OIDCAuthRequest{
|
||||
ConnectorID: sso.ConnectorId,
|
||||
Type: sso.ConnectorType,
|
||||
ClientRedirectURL: ssoClientRedirectURL,
|
||||
CheckUser: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
chal.RequestId = resp.StateToken
|
||||
chal.RedirectUrl = resp.RedirectURL
|
||||
default:
|
||||
return nil, trace.BadParameter("unsupported sso connector type %v", sso.ConnectorType)
|
||||
}
|
||||
|
||||
if err := a.upsertSSOMFASession(ctx, user, chal.RequestId, sso.ConnectorId, sso.ConnectorType, ext); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
return chal, nil
|
||||
}
|
||||
|
||||
// verifySSOMFASession verifies that the given sso mfa token 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) verifySSOMFASession(ctx context.Context, username, sessionID, token string, requiredExtensions *mfav1.ChallengeExtensions) (*authz.MFAAuthData, error) {
|
||||
if requiredExtensions == nil {
|
||||
return nil, trace.BadParameter("requested challenge extensions must be supplied.")
|
||||
}
|
||||
|
||||
const notFoundErrMsg = "mfa sso session data not found"
|
||||
mfaSess, err := a.GetSSOMFASessionData(ctx, sessionID)
|
||||
if trace.IsNotFound(err) {
|
||||
return nil, trace.AccessDenied(notFoundErrMsg)
|
||||
} else if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
// Verify the user's name and sso device matches.
|
||||
if mfaSess.Username != username {
|
||||
return nil, trace.AccessDenied(notFoundErrMsg)
|
||||
}
|
||||
|
||||
// Check if the MFA session matches the user's SSO MFA settings.
|
||||
devs, err := a.Services.GetMFADevices(ctx, username, false /* withSecrets */)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
groupedDevs := groupByDeviceType(devs)
|
||||
if groupedDevs.SSO == nil {
|
||||
return nil, trace.AccessDenied("invalid sso mfa session data; non-sso user")
|
||||
} else if groupedDevs.SSO.GetSso().ConnectorId != mfaSess.ConnectorID || groupedDevs.SSO.GetSso().ConnectorType != mfaSess.ConnectorType {
|
||||
return nil, trace.AccessDenied("invalid sso mfa session data; mismatched sso auth connector")
|
||||
}
|
||||
|
||||
// Verify the token matches.
|
||||
if subtle.ConstantTimeCompare([]byte(mfaSess.Token), []byte(token)) == 0 {
|
||||
return nil, trace.AccessDenied("invalid SSO MFA challenge response")
|
||||
}
|
||||
|
||||
// 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 sso mfa session with scope %q", requiredExtensions.Scope, mfaSess.ChallengeExtensions.Scope)
|
||||
}
|
||||
|
||||
// If this session is reusable, but this context forbids reusable sessions, return an error.
|
||||
if requiredExtensions.AllowReuse == mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_NO && mfaSess.ChallengeExtensions.AllowReuse == mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_YES {
|
||||
return nil, trace.AccessDenied("the given sso mfa session allows reuse, but reuse is not permitted in this context")
|
||||
}
|
||||
|
||||
if mfaSess.ChallengeExtensions.AllowReuse != mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_YES {
|
||||
if err := a.DeleteSSOMFASessionData(ctx, sessionID); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
return &authz.MFAAuthData{
|
||||
Device: groupedDevs.SSO,
|
||||
User: username,
|
||||
AllowReuse: mfaSess.ChallengeExtensions.AllowReuse,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,752 @@
|
||||
/*
|
||||
* 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 auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gravitational/trace"
|
||||
"github.com/jonboulle/clockwork"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"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"
|
||||
"github.com/gravitational/teleport/api/types"
|
||||
"github.com/gravitational/teleport/lib/auth/authclient"
|
||||
"github.com/gravitational/teleport/lib/authz"
|
||||
"github.com/gravitational/teleport/lib/defaults"
|
||||
"github.com/gravitational/teleport/lib/services"
|
||||
)
|
||||
|
||||
func TestSSOMFAChallenge_Creation(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
fakeClock := clockwork.NewFakeClock()
|
||||
testAuthServer, err := NewTestAuthServer(TestAuthServerConfig{
|
||||
Dir: t.TempDir(),
|
||||
Clock: fakeClock,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
testServer, err := testAuthServer.NewTestTLSServer()
|
||||
require.NoError(t, err)
|
||||
|
||||
a := testServer.Auth()
|
||||
a.SetSAMLService(&fakeSSOService{a})
|
||||
a.SetOIDCService(&fakeSSOService{a})
|
||||
|
||||
// Enable SSO MFA support.
|
||||
authPref, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
||||
Type: constants.Local,
|
||||
SecondFactors: []types.SecondFactorType{
|
||||
types.SecondFactorType_SECOND_FACTOR_TYPE_SSO,
|
||||
},
|
||||
AllowLocalAuth: types.NewBoolOption(false),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = a.UpsertAuthPreference(ctx, authPref)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a standard user.
|
||||
standardUser, _, err := CreateUserAndRole(a, "standard", []string{"role"}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a fake saml user with MFA disabled.
|
||||
noMFASAMLUser, noMFASAMLRole, err := CreateUserAndRole(a, "saml-user-no-mfa", []string{"role"}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
noMFASAMLConnector, err := types.NewSAMLConnector("saml-no-mfa", types.SAMLConnectorSpecV2{
|
||||
AssertionConsumerService: "http://localhost:65535/acs", // not called
|
||||
Issuer: "test",
|
||||
SSO: "https://localhost:65535/sso", // not called
|
||||
AttributesToRoles: []types.AttributeMapping{
|
||||
// not used. can be any name, value but role must exist
|
||||
{Name: "groups", Value: "admin", Roles: []string{noMFASAMLRole.GetName()}},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = a.UpsertSAMLConnector(ctx, noMFASAMLConnector)
|
||||
require.NoError(t, err)
|
||||
|
||||
noMFASAMLUser.SetCreatedBy(types.CreatedBy{
|
||||
Time: a.clock.Now(),
|
||||
Connector: &types.ConnectorRef{
|
||||
ID: noMFASAMLConnector.GetName(),
|
||||
Type: noMFASAMLConnector.GetKind(),
|
||||
},
|
||||
})
|
||||
_, err = a.UpsertUser(ctx, noMFASAMLUser)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a fake saml user with MFA enabled.
|
||||
samlUser, samlRole, err := CreateUserAndRole(a, "saml-user", []string{"role"}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
samlConnector, err := types.NewSAMLConnector("saml", types.SAMLConnectorSpecV2{
|
||||
AssertionConsumerService: "http://localhost:65535/acs", // not called
|
||||
Issuer: "test",
|
||||
SSO: "https://localhost:65535/sso", // not called
|
||||
AttributesToRoles: []types.AttributeMapping{
|
||||
// not used. can be any name, value but role must exist
|
||||
{Name: "groups", Value: "admin", Roles: []string{samlRole.GetName()}},
|
||||
},
|
||||
MFASettings: &types.SAMLConnectorMFASettings{
|
||||
Enabled: true,
|
||||
Issuer: "test",
|
||||
Sso: "https://localhost:65535/sso", // not called
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = a.UpsertSAMLConnector(ctx, samlConnector)
|
||||
require.NoError(t, err)
|
||||
|
||||
samlUser.SetCreatedBy(types.CreatedBy{
|
||||
Time: a.clock.Now(),
|
||||
Connector: &types.ConnectorRef{
|
||||
ID: samlConnector.GetName(),
|
||||
Type: samlConnector.GetKind(),
|
||||
},
|
||||
})
|
||||
_, err = a.UpsertUser(ctx, samlUser)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a fake oidc user with MFA enabled.
|
||||
oidcUser, oidcRole, err := CreateUserAndRole(a, "oidc-user", []string{"role"}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
oidcConnector, err := types.NewOIDCConnector("oidc", types.OIDCConnectorSpecV3{
|
||||
ClientID: "12345",
|
||||
ClientSecret: "678910",
|
||||
RedirectURLs: []string{"https://proxy.example.com/v1/webapi/oidc/callback"},
|
||||
ClaimsToRoles: []types.ClaimMapping{
|
||||
{
|
||||
Claim: "test",
|
||||
Value: "test",
|
||||
Roles: []string{oidcRole.GetName()},
|
||||
},
|
||||
},
|
||||
MFASettings: &types.OIDCConnectorMFASettings{
|
||||
Enabled: true,
|
||||
ClientId: "12345",
|
||||
ClientSecret: "678910",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = a.UpsertOIDCConnector(ctx, oidcConnector)
|
||||
require.NoError(t, err)
|
||||
|
||||
oidcUser.SetCreatedBy(types.CreatedBy{
|
||||
Time: a.clock.Now(),
|
||||
Connector: &types.ConnectorRef{
|
||||
ID: oidcConnector.GetName(),
|
||||
Type: oidcConnector.GetKind(),
|
||||
},
|
||||
})
|
||||
_, err = a.UpsertUser(ctx, oidcUser)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
username string
|
||||
setup func(t *testing.T)
|
||||
challengeRequest *proto.CreateAuthenticateChallengeRequest
|
||||
assertChallenge func(t *testing.T, chal *proto.MFAAuthenticateChallenge, err error)
|
||||
}{
|
||||
{
|
||||
name: "NOK non sso user",
|
||||
username: standardUser.GetName(),
|
||||
challengeRequest: &proto.CreateAuthenticateChallengeRequest{
|
||||
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN, // which scope doesn't matter here.
|
||||
},
|
||||
SSOClientRedirectURL: "/web/home", // value doesn't matter, as long as it isn't empty.
|
||||
},
|
||||
assertChallenge: func(t *testing.T, chal *proto.MFAAuthenticateChallenge, err error) {
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, chal.SSOChallenge)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NOK sso mfa not enabled by auth connector",
|
||||
username: noMFASAMLUser.GetName(),
|
||||
challengeRequest: &proto.CreateAuthenticateChallengeRequest{
|
||||
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN, // which scope doesn't matter here.
|
||||
},
|
||||
SSOClientRedirectURL: "/web/home", // value doesn't matter, as long as it isn't empty.
|
||||
},
|
||||
assertChallenge: func(t *testing.T, chal *proto.MFAAuthenticateChallenge, err error) {
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, chal.SSOChallenge)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NOK sso mfa not enabled by auth preference",
|
||||
username: samlUser.GetName(),
|
||||
challengeRequest: &proto.CreateAuthenticateChallengeRequest{
|
||||
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN, // which scope doesn't matter here.
|
||||
},
|
||||
SSOClientRedirectURL: "/web/home", // value doesn't matter, as long as it isn't empty.
|
||||
},
|
||||
setup: func(t *testing.T) {
|
||||
// disable SSO MFA.
|
||||
authPref.SetSecondFactors()
|
||||
require.NoError(t, err)
|
||||
_, err = a.UpsertAuthPreference(ctx, authPref)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
authPref.SetSecondFactors(types.SecondFactorType_SECOND_FACTOR_TYPE_SSO)
|
||||
require.NoError(t, err)
|
||||
_, err = a.UpsertAuthPreference(ctx, authPref)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
},
|
||||
assertChallenge: func(t *testing.T, chal *proto.MFAAuthenticateChallenge, err error) {
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, chal.SSOChallenge)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NOK SSOClientRedirectURL not provided",
|
||||
username: samlUser.GetName(),
|
||||
challengeRequest: &proto.CreateAuthenticateChallengeRequest{
|
||||
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN, // which scope doesn't matter here.
|
||||
},
|
||||
SSOClientRedirectURL: "",
|
||||
},
|
||||
assertChallenge: func(t *testing.T, chal *proto.MFAAuthenticateChallenge, err error) {
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, chal.SSOChallenge)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OK saml user",
|
||||
username: samlUser.GetName(),
|
||||
challengeRequest: &proto.CreateAuthenticateChallengeRequest{
|
||||
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN, // which scope doesn't matter here.
|
||||
},
|
||||
SSOClientRedirectURL: "/web/home", // value doesn't matter, as long as it isn't empty.
|
||||
},
|
||||
assertChallenge: func(t *testing.T, chal *proto.MFAAuthenticateChallenge, err error) {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, chal.SSOChallenge, "expected SSO challenge to be returned")
|
||||
assert.NotEmpty(t, chal.SSOChallenge.RedirectUrl)
|
||||
assert.NotEmpty(t, chal.SSOChallenge.RequestId)
|
||||
|
||||
// We should find an auth request with the resulting request ID.
|
||||
req, err := a.GetSAMLAuthRequest(ctx, chal.SSOChallenge.RequestId)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, chal.SSOChallenge.RedirectUrl, req.RedirectURL)
|
||||
assert.Equal(t, chal.SSOChallenge.RequestId, req.ID)
|
||||
assert.Equal(t, "/web/home", req.ClientRedirectURL)
|
||||
assert.Equal(t, samlConnector.GetName(), req.ConnectorID)
|
||||
assert.Equal(t, samlConnector.GetKind(), req.Type)
|
||||
assert.True(t, req.CheckUser)
|
||||
|
||||
// We should find non validated SSO MFA session data tied to the challenge by auth request ID.
|
||||
sd, err := a.GetSSOMFASessionData(ctx, chal.SSOChallenge.RequestId)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, &services.SSOMFASessionData{
|
||||
RequestID: chal.SSOChallenge.RequestId,
|
||||
Username: samlUser.GetName(),
|
||||
ConnectorID: samlConnector.GetName(),
|
||||
ConnectorType: samlConnector.GetKind(),
|
||||
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
||||
},
|
||||
}, sd)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OK oidc user",
|
||||
username: oidcUser.GetName(),
|
||||
challengeRequest: &proto.CreateAuthenticateChallengeRequest{
|
||||
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN, // which scope doesn't matter here.
|
||||
},
|
||||
SSOClientRedirectURL: "/web/home", // value doesn't matter, as long as it isn't empty.
|
||||
},
|
||||
assertChallenge: func(t *testing.T, chal *proto.MFAAuthenticateChallenge, err error) {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, chal.SSOChallenge, "expected SSO challenge to be returned")
|
||||
assert.NotEmpty(t, chal.SSOChallenge.RedirectUrl)
|
||||
assert.NotEmpty(t, chal.SSOChallenge.RequestId)
|
||||
|
||||
// We should find an auth request with the resulting request ID.
|
||||
req, err := a.GetOIDCAuthRequest(ctx, chal.SSOChallenge.RequestId)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, chal.SSOChallenge.RedirectUrl, req.RedirectURL)
|
||||
assert.Equal(t, chal.SSOChallenge.RequestId, req.StateToken)
|
||||
assert.Equal(t, "/web/home", req.ClientRedirectURL)
|
||||
assert.Equal(t, oidcConnector.GetName(), req.ConnectorID)
|
||||
assert.Equal(t, oidcConnector.GetKind(), req.Type)
|
||||
assert.True(t, req.CheckUser)
|
||||
|
||||
// We should find non validated SSO MFA session data tied to the challenge by auth request ID.
|
||||
sd, err := a.GetSSOMFASessionData(ctx, chal.SSOChallenge.RequestId)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, &services.SSOMFASessionData{
|
||||
RequestID: chal.SSOChallenge.RequestId,
|
||||
Username: oidcUser.GetName(),
|
||||
ConnectorID: oidcConnector.GetName(),
|
||||
ConnectorType: oidcConnector.GetKind(),
|
||||
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
||||
},
|
||||
}, sd)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OK allow reuse",
|
||||
username: samlUser.GetName(),
|
||||
challengeRequest: &proto.CreateAuthenticateChallengeRequest{
|
||||
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN, // which scope doesn't matter here.
|
||||
AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_YES,
|
||||
},
|
||||
SSOClientRedirectURL: "/web/home", // value doesn't matter, as long as it isn't empty.
|
||||
},
|
||||
assertChallenge: func(t *testing.T, chal *proto.MFAAuthenticateChallenge, err error) {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, chal.SSOChallenge, "expected SSO challenge to be returned")
|
||||
|
||||
// We should find non validated SSO MFA session data tied to the challenge by auth request ID.
|
||||
sd, err := a.GetSSOMFASessionData(ctx, chal.SSOChallenge.RequestId)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_YES, sd.ChallengeExtensions.AllowReuse)
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
userClient, err := testServer.NewClient(TestUser(tt.username))
|
||||
require.NoError(t, err)
|
||||
|
||||
if tt.setup != nil {
|
||||
tt.setup(t)
|
||||
}
|
||||
|
||||
chal, err := userClient.CreateAuthenticateChallenge(ctx, tt.challengeRequest)
|
||||
tt.assertChallenge(t, chal, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSOMFAChallenge_Validation(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
fakeClock := clockwork.NewFakeClock()
|
||||
testAuthServer, err := NewTestAuthServer(TestAuthServerConfig{
|
||||
Dir: t.TempDir(),
|
||||
Clock: fakeClock,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
testServer, err := testAuthServer.NewTestTLSServer()
|
||||
require.NoError(t, err)
|
||||
|
||||
a := testServer.Auth()
|
||||
|
||||
// Create a standard user.
|
||||
standardUser, _, err := CreateUserAndRole(a, "standard", []string{"role"}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a fake saml user with MFA enabled.
|
||||
samlUser, samlRole, err := CreateUserAndRole(a, "saml-user", []string{"role"}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
samlConnector, err := types.NewSAMLConnector("saml", types.SAMLConnectorSpecV2{
|
||||
AssertionConsumerService: "http://localhost:65535/acs", // not called
|
||||
Issuer: "test",
|
||||
SSO: "https://localhost:65535/sso", // not called
|
||||
AttributesToRoles: []types.AttributeMapping{
|
||||
// not used. can be any name, value but role must exist
|
||||
{Name: "groups", Value: "admin", Roles: []string{samlRole.GetName()}},
|
||||
},
|
||||
MFASettings: &types.SAMLConnectorMFASettings{
|
||||
Enabled: true,
|
||||
Issuer: "test",
|
||||
Sso: "https://localhost:65535/sso", // not called
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = a.UpsertSAMLConnector(ctx, samlConnector)
|
||||
require.NoError(t, err)
|
||||
|
||||
userCreatedAt := a.clock.Now().UTC()
|
||||
samlUser.SetCreatedBy(types.CreatedBy{
|
||||
Time: userCreatedAt,
|
||||
Connector: &types.ConnectorRef{
|
||||
ID: samlConnector.GetName(),
|
||||
Type: samlConnector.GetKind(),
|
||||
},
|
||||
})
|
||||
_, err = a.UpsertUser(ctx, samlUser)
|
||||
require.NoError(t, err)
|
||||
|
||||
ssoDevice, err := types.NewMFADevice(samlConnector.GetDisplay(), samlConnector.GetName(), userCreatedAt, &types.MFADevice_Sso{
|
||||
Sso: &types.SSOMFADevice{
|
||||
ConnectorId: samlConnector.GetName(),
|
||||
ConnectorType: samlConnector.GetKind(),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a fake saml user with MFA disabled.
|
||||
noMFASAMLUser, noMFASAMLRole, err := CreateUserAndRole(a, "saml-user-no-mfa", []string{"role"}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
noMFASAMLConnector, err := types.NewSAMLConnector("saml-no-mfa", types.SAMLConnectorSpecV2{
|
||||
AssertionConsumerService: "http://localhost:65535/acs", // not called
|
||||
Issuer: "test",
|
||||
SSO: "https://localhost:65535/sso", // not called
|
||||
AttributesToRoles: []types.AttributeMapping{
|
||||
// not used. can be any name, value but role must exist
|
||||
{Name: "groups", Value: "admin", Roles: []string{noMFASAMLRole.GetName()}},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = a.UpsertSAMLConnector(ctx, noMFASAMLConnector)
|
||||
require.NoError(t, err)
|
||||
|
||||
noMFASAMLUser.SetCreatedBy(types.CreatedBy{
|
||||
Time: a.clock.Now(),
|
||||
Connector: &types.ConnectorRef{
|
||||
ID: noMFASAMLConnector.GetName(),
|
||||
Type: noMFASAMLConnector.GetKind(),
|
||||
},
|
||||
})
|
||||
_, err = a.UpsertUser(ctx, noMFASAMLUser)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
username string
|
||||
sd *services.SSOMFASessionData
|
||||
ssoResponse *proto.SSOResponse
|
||||
requiredExtensions *mfav1.ChallengeExtensions
|
||||
assertValidation func(t *testing.T, mad *authz.MFAAuthData, err error)
|
||||
}{
|
||||
{
|
||||
name: "NOK no required extensions data",
|
||||
username: samlUser.GetName(),
|
||||
sd: nil,
|
||||
ssoResponse: nil,
|
||||
assertValidation: func(t *testing.T, mad *authz.MFAAuthData, err error) {
|
||||
require.True(t, trace.IsBadParameter(err), "expected bad parameter error but got %v", err)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NOK no session data",
|
||||
username: samlUser.GetName(),
|
||||
sd: nil,
|
||||
ssoResponse: &proto.SSOResponse{
|
||||
RequestId: "unknown",
|
||||
Token: "token",
|
||||
},
|
||||
requiredExtensions: &mfav1.ChallengeExtensions{},
|
||||
assertValidation: func(t *testing.T, mad *authz.MFAAuthData, err error) {
|
||||
require.True(t, trace.IsAccessDenied(err), "expected access denied error but got %v", err)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NOK mismatch user",
|
||||
username: samlUser.GetName(),
|
||||
sd: &services.SSOMFASessionData{
|
||||
RequestID: "request1",
|
||||
Username: "wrong-user",
|
||||
ConnectorID: samlConnector.GetName(),
|
||||
ConnectorType: samlConnector.GetKind(),
|
||||
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
||||
},
|
||||
Token: "token",
|
||||
},
|
||||
ssoResponse: &proto.SSOResponse{
|
||||
RequestId: "request1",
|
||||
Token: "token",
|
||||
},
|
||||
requiredExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
||||
},
|
||||
assertValidation: func(t *testing.T, mad *authz.MFAAuthData, err error) {
|
||||
require.True(t, trace.IsAccessDenied(err), "expected access denied error but got %v", err)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NOK mismatch token",
|
||||
username: samlUser.GetName(),
|
||||
sd: &services.SSOMFASessionData{
|
||||
RequestID: "request2",
|
||||
Username: samlUser.GetName(),
|
||||
ConnectorID: samlConnector.GetName(),
|
||||
ConnectorType: samlConnector.GetKind(),
|
||||
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
||||
},
|
||||
Token: "token",
|
||||
},
|
||||
ssoResponse: &proto.SSOResponse{
|
||||
RequestId: "request2",
|
||||
Token: "wrong-token",
|
||||
},
|
||||
requiredExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
||||
},
|
||||
assertValidation: func(t *testing.T, mad *authz.MFAAuthData, err error) {
|
||||
require.True(t, trace.IsAccessDenied(err), "expected access denied error but got %v", err)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NOK non validated session data",
|
||||
username: samlUser.GetName(),
|
||||
sd: &services.SSOMFASessionData{
|
||||
RequestID: "request2",
|
||||
Username: samlUser.GetName(),
|
||||
ConnectorID: samlConnector.GetName(),
|
||||
ConnectorType: samlConnector.GetKind(),
|
||||
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
||||
},
|
||||
},
|
||||
ssoResponse: &proto.SSOResponse{
|
||||
RequestId: "request2",
|
||||
Token: "token",
|
||||
},
|
||||
requiredExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
||||
},
|
||||
assertValidation: func(t *testing.T, mad *authz.MFAAuthData, err error) {
|
||||
require.True(t, trace.IsAccessDenied(err), "expected access denied error but got %v", err)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NOK mismatch scope",
|
||||
username: samlUser.GetName(),
|
||||
sd: &services.SSOMFASessionData{
|
||||
RequestID: "request3",
|
||||
Username: samlUser.GetName(),
|
||||
ConnectorID: samlConnector.GetName(),
|
||||
ConnectorType: samlConnector.GetKind(),
|
||||
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
||||
},
|
||||
Token: "token",
|
||||
},
|
||||
ssoResponse: &proto.SSOResponse{
|
||||
RequestId: "request3",
|
||||
Token: "token",
|
||||
},
|
||||
requiredExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_ADMIN_ACTION,
|
||||
},
|
||||
assertValidation: func(t *testing.T, mad *authz.MFAAuthData, err error) {
|
||||
require.True(t, trace.IsAccessDenied(err), "expected access denied error but got %v", err)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NOK reuse not allowed",
|
||||
username: samlUser.GetName(),
|
||||
sd: &services.SSOMFASessionData{
|
||||
RequestID: "request4",
|
||||
Username: samlUser.GetName(),
|
||||
ConnectorID: samlConnector.GetName(),
|
||||
ConnectorType: samlConnector.GetKind(),
|
||||
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
||||
AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_YES,
|
||||
},
|
||||
Token: "token",
|
||||
},
|
||||
ssoResponse: &proto.SSOResponse{
|
||||
RequestId: "request4",
|
||||
Token: "token",
|
||||
},
|
||||
requiredExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
||||
AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_NO,
|
||||
},
|
||||
assertValidation: func(t *testing.T, mad *authz.MFAAuthData, err error) {
|
||||
require.True(t, trace.IsAccessDenied(err), "expected access denied error but got %v", err)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NOK sso mfa not enabled by auth connector",
|
||||
username: noMFASAMLUser.GetName(),
|
||||
sd: &services.SSOMFASessionData{
|
||||
RequestID: "request5",
|
||||
Username: noMFASAMLUser.GetName(),
|
||||
ConnectorID: noMFASAMLConnector.GetName(),
|
||||
ConnectorType: noMFASAMLConnector.GetKind(),
|
||||
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
||||
},
|
||||
Token: "token",
|
||||
},
|
||||
ssoResponse: &proto.SSOResponse{
|
||||
RequestId: "request5",
|
||||
Token: "token",
|
||||
},
|
||||
requiredExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
||||
},
|
||||
assertValidation: func(t *testing.T, mad *authz.MFAAuthData, err error) {
|
||||
require.True(t, trace.IsAccessDenied(err), "expected access denied error but got %v", err)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NOK non sso user",
|
||||
username: standardUser.GetName(),
|
||||
sd: &services.SSOMFASessionData{
|
||||
RequestID: "request6",
|
||||
Username: standardUser.GetName(),
|
||||
ConnectorID: samlConnector.GetName(),
|
||||
ConnectorType: samlConnector.GetKind(),
|
||||
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
||||
},
|
||||
Token: "token",
|
||||
},
|
||||
ssoResponse: &proto.SSOResponse{
|
||||
RequestId: "request6",
|
||||
Token: "token",
|
||||
},
|
||||
requiredExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
||||
},
|
||||
assertValidation: func(t *testing.T, mad *authz.MFAAuthData, err error) {
|
||||
require.True(t, trace.IsAccessDenied(err), "expected access denied error but got %v", err)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OK sso user",
|
||||
username: samlUser.GetName(),
|
||||
sd: &services.SSOMFASessionData{
|
||||
RequestID: "request7",
|
||||
Username: samlUser.GetName(),
|
||||
ConnectorID: samlConnector.GetName(),
|
||||
ConnectorType: samlConnector.GetKind(),
|
||||
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
||||
AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_NO,
|
||||
},
|
||||
Token: "token",
|
||||
},
|
||||
ssoResponse: &proto.SSOResponse{
|
||||
RequestId: "request7",
|
||||
Token: "token",
|
||||
},
|
||||
requiredExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
||||
},
|
||||
assertValidation: func(t *testing.T, mad *authz.MFAAuthData, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, &authz.MFAAuthData{
|
||||
User: samlUser.GetName(),
|
||||
Device: ssoDevice,
|
||||
AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_NO,
|
||||
}, mad)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OK sso user allow reuse",
|
||||
username: samlUser.GetName(),
|
||||
sd: &services.SSOMFASessionData{
|
||||
RequestID: "request8",
|
||||
Username: samlUser.GetName(),
|
||||
ConnectorID: samlConnector.GetName(),
|
||||
ConnectorType: samlConnector.GetKind(),
|
||||
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
||||
AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_YES,
|
||||
},
|
||||
Token: "token",
|
||||
},
|
||||
ssoResponse: &proto.SSOResponse{
|
||||
RequestId: "request8",
|
||||
Token: "token",
|
||||
},
|
||||
requiredExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
||||
AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_YES,
|
||||
},
|
||||
assertValidation: func(t *testing.T, mad *authz.MFAAuthData, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, &authz.MFAAuthData{
|
||||
User: samlUser.GetName(),
|
||||
Device: ssoDevice,
|
||||
AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_YES,
|
||||
}, mad)
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.sd != nil {
|
||||
err := a.UpsertSSOMFASessionData(ctx, tt.sd)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
data, err := a.ValidateMFAAuthResponse(ctx, &proto.MFAAuthenticateResponse{
|
||||
Response: &proto.MFAAuthenticateResponse_SSO{
|
||||
SSO: tt.ssoResponse,
|
||||
},
|
||||
}, tt.username, tt.requiredExtensions)
|
||||
tt.assertValidation(t, data, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type fakeSSOService struct {
|
||||
a *Server
|
||||
}
|
||||
|
||||
func (s *fakeSSOService) CreateSAMLAuthRequest(ctx context.Context, req types.SAMLAuthRequest) (*types.SAMLAuthRequest, error) {
|
||||
return nil, nil // unused in these tests.
|
||||
}
|
||||
|
||||
func (s *fakeSSOService) CreateSAMLAuthRequestForMFA(ctx context.Context, req types.SAMLAuthRequest) (*types.SAMLAuthRequest, error) {
|
||||
req.ID = uuid.NewString()
|
||||
req.RedirectURL = uuid.NewString()
|
||||
return &req, s.a.Services.CreateSAMLAuthRequest(ctx, req, defaults.SAMLAuthRequestTTL)
|
||||
}
|
||||
|
||||
func (s *fakeSSOService) ValidateSAMLResponse(ctx context.Context, samlResponse, connectorID, clientIP string) (*authclient.SAMLAuthResponse, error) {
|
||||
return nil, nil // unused in these tests.
|
||||
}
|
||||
|
||||
func (s *fakeSSOService) CreateOIDCAuthRequest(ctx context.Context, req types.OIDCAuthRequest) (*types.OIDCAuthRequest, error) {
|
||||
return nil, nil // unused in these tests.
|
||||
}
|
||||
|
||||
func (s *fakeSSOService) CreateOIDCAuthRequestForMFA(ctx context.Context, req types.OIDCAuthRequest) (*types.OIDCAuthRequest, error) {
|
||||
req.StateToken = uuid.NewString()
|
||||
req.RedirectURL = uuid.NewString()
|
||||
return &req, s.a.Services.CreateOIDCAuthRequest(ctx, req, defaults.OIDCAuthRequestTTL)
|
||||
}
|
||||
|
||||
func (s *fakeSSOService) ValidateOIDCAuthCallback(ctx context.Context, q url.Values) (*authclient.OIDCAuthResponse, error) {
|
||||
return nil, nil // unused in these tests.
|
||||
}
|
||||
Reference in New Issue
Block a user