Add headless auth preference logic. (#22148)

This commit is contained in:
Brian Joerger
2023-02-27 18:25:51 +00:00
committed by GitHub
parent 93d7831ced
commit 1dcc997189
12 changed files with 1608 additions and 1335 deletions
+2
View File
@@ -377,6 +377,8 @@ type AuthenticationSettings struct {
PreferredLocalMFA constants.SecondFactorType `json:"preferred_local_mfa,omitempty"`
// AllowPasswordless is true if passwordless logins are allowed.
AllowPasswordless bool `json:"allow_passwordless,omitempty"`
// AllowHeadless is true if headless logins are allowed.
AllowHeadless bool `json:"allow_headless,omitempty"`
// Local contains settings for local authentication.
Local *LocalSettings `json:"local,omitempty"`
// Webauthn contains MFA settings for Web Authentication.
+4
View File
@@ -56,6 +56,9 @@ const (
// local/passwordless logins.
PasswordlessConnector = "passwordless"
// HeadlessConnector is the authentication connector for headless logins.
HeadlessConnector = "headless"
// Local means authentication will happen locally within the Teleport cluster.
Local = "local"
@@ -160,6 +163,7 @@ const (
var SystemConnectors = []string{
LocalConnector,
PasswordlessConnector,
HeadlessConnector,
}
// SecondFactorType is the type of 2FA authentication.
@@ -1614,6 +1614,16 @@ message AuthPreferenceSpecV2 {
// IDP is a set of options related to accessing IdPs within Teleport.
// Requires Teleport Enterprise.
IdPOptions IDP = 14 [(gogoproto.jsontag) = "idp,omitempty"];
// AllowHeadless enables/disables headless support.
// Headless authentication requires Webauthn to work.
// Defaults to true if the Webauthn is configured, defaults to false
// otherwise.
BoolValue AllowHeadless = 15 [
(gogoproto.nullable) = true,
(gogoproto.jsontag) = "allow_headless,omitempty",
(gogoproto.customtype) = "BoolOption"
];
}
// U2F defines settings for U2F device.
+25
View File
@@ -86,6 +86,11 @@ type AuthPreference interface {
// SetAllowPasswordless sets the value of the allow passwordless setting.
SetAllowPasswordless(b bool)
// GetAllowHeadless returns if headless is allowed by cluster settings.
GetAllowHeadless() bool
// SetAllowHeadless sets the value of the allow headless setting.
SetAllowHeadless(b bool)
// GetRequireMFAType returns the type of MFA requirement enforced for this cluster.
GetRequireMFAType() RequireMFAType
// GetPrivateKeyPolicy returns the configured private key policy for the cluster.
@@ -341,6 +346,14 @@ func (c *AuthPreferenceV2) SetAllowPasswordless(b bool) {
c.Spec.AllowPasswordless = NewBoolOption(b)
}
func (c *AuthPreferenceV2) GetAllowHeadless() bool {
return c.Spec.AllowHeadless != nil && c.Spec.AllowHeadless.Value
}
func (c *AuthPreferenceV2) SetAllowHeadless(b bool) {
c.Spec.AllowHeadless = NewBoolOption(b)
}
// GetRequireMFAType returns the type of MFA requirement enforced for this cluster.
func (c *AuthPreferenceV2) GetRequireMFAType() RequireMFAType {
return c.Spec.RequireMFAType
@@ -535,6 +548,14 @@ func (c *AuthPreferenceV2) CheckAndSetDefaults() error {
return trace.BadParameter("missing required Webauthn configuration for passwordless=true")
}
// Set/validate AllowHeadless. We need Webauthn first to do this properly.
switch {
case c.Spec.AllowHeadless == nil:
c.Spec.AllowHeadless = NewBoolOption(hasWebauthn)
case !hasWebauthn && c.Spec.AllowHeadless.Value:
return trace.BadParameter("missing required Webauthn configuration for headless=true")
}
// Validate connector name for type=local.
if c.Spec.Type == constants.Local {
switch connectorName := c.Spec.ConnectorName; connectorName {
@@ -543,6 +564,10 @@ func (c *AuthPreferenceV2) CheckAndSetDefaults() error {
if !c.Spec.AllowPasswordless.Value {
return trace.BadParameter("invalid local connector %q, passwordless not allowed by cluster settings", connectorName)
}
case constants.HeadlessConnector:
if !c.Spec.AllowHeadless.Value {
return trace.BadParameter("invalid local connector %q, headless not allowed by cluster settings", connectorName)
}
default:
return trace.BadParameter("invalid local connector %q", connectorName)
}
@@ -454,6 +454,83 @@ func TestAuthPreferenceV2_CheckAndSetDefaults_secondFactor(t *testing.T) {
assert.True(t, cap.GetAllowPasswordless(), "AllowPasswordless")
},
},
// AllowHeadless
{
name: "OK AllowHeadless defaults to false without Webauthn",
secondFactors: []constants.SecondFactorType{
constants.SecondFactorOff,
constants.SecondFactorOTP,
},
spec: types.AuthPreferenceSpecV2{
Type: constants.Local,
AllowHeadless: nil, // aka unset
},
assertFn: func(t *testing.T, cap *types.AuthPreferenceV2) {
assert.False(t, cap.GetAllowHeadless(), "AllowHeadless")
},
},
{
name: "OK AllowHeadless=false without Webauthn",
secondFactors: []constants.SecondFactorType{
constants.SecondFactorOff,
constants.SecondFactorOTP,
},
spec: types.AuthPreferenceSpecV2{
Type: constants.Local,
AllowHeadless: types.NewBoolOption(false),
},
assertFn: func(t *testing.T, cap *types.AuthPreferenceV2) {
assert.False(t, cap.GetAllowHeadless(), "AllowHeadless")
},
},
{
name: "NOK AllowHeadless=true without Webauthn",
secondFactors: []constants.SecondFactorType{
constants.SecondFactorOff,
constants.SecondFactorOTP,
},
spec: types.AuthPreferenceSpecV2{
Type: constants.Local,
AllowHeadless: types.NewBoolOption(true),
},
wantErr: "required Webauthn",
},
{
name: "OK AllowHeadless defaults to true with Webauthn",
secondFactors: secondFactorWebActive,
spec: types.AuthPreferenceSpecV2{
Type: constants.Local,
Webauthn: minimalWeb,
AllowHeadless: nil, // aka unset
},
assertFn: func(t *testing.T, cap *types.AuthPreferenceV2) {
assert.True(t, cap.GetAllowHeadless(), "AllowHeadless")
},
},
{
name: "OK AllowHeadless=false with Webauthn",
secondFactors: secondFactorWebActive,
spec: types.AuthPreferenceSpecV2{
Type: constants.Local,
Webauthn: minimalWeb,
AllowHeadless: types.NewBoolOption(false),
},
assertFn: func(t *testing.T, cap *types.AuthPreferenceV2) {
assert.False(t, cap.GetAllowHeadless(), "AllowHeadless")
},
},
{
name: "OK AllowHeadless=true with Webauthn",
secondFactors: secondFactorWebActive,
spec: types.AuthPreferenceSpecV2{
Type: constants.Local,
Webauthn: minimalWeb,
AllowHeadless: types.NewBoolOption(true),
},
assertFn: func(t *testing.T, cap *types.AuthPreferenceV2) {
assert.True(t, cap.GetAllowHeadless(), "AllowHeadless")
},
},
// ConnectorName
{
name: "OK type=local and local connector",
@@ -492,6 +569,15 @@ func TestAuthPreferenceV2_CheckAndSetDefaults_secondFactor(t *testing.T) {
Webauthn: minimalWeb,
},
},
{
name: "OK type=local and headless connector",
secondFactors: secondFactorWebActive,
spec: types.AuthPreferenceSpecV2{
Type: constants.Local,
ConnectorName: constants.HeadlessConnector,
Webauthn: minimalWeb,
},
},
{
name: "NOK type=local and passwordless connector",
secondFactors: []constants.SecondFactorType{
@@ -516,6 +602,31 @@ func TestAuthPreferenceV2_CheckAndSetDefaults_secondFactor(t *testing.T) {
},
wantErr: "passwordless not allowed",
},
{
name: "NOK type=local and headless connector",
secondFactors: []constants.SecondFactorType{
constants.SecondFactorOff, // webauthn disabled
constants.SecondFactorOTP,
},
spec: types.AuthPreferenceSpecV2{
Type: constants.Local,
ConnectorName: constants.HeadlessConnector,
Webauthn: minimalWeb,
},
wantErr: "headless not allowed",
},
{
name: "NOK type=local, allow_headless=false and headless connector",
secondFactors: secondFactorWebActive,
spec: types.AuthPreferenceSpecV2{
Type: constants.Local,
ConnectorName: constants.HeadlessConnector,
Webauthn: minimalWeb,
AllowHeadless: types.NewBoolOption(false),
},
wantErr: "headless not allowed",
},
{
name: "NOK type=local and unknown connector",
secondFactors: secondFactorAll,
+1375 -1316
View File
File diff suppressed because it is too large Load Diff
+33 -19
View File
@@ -3126,32 +3126,46 @@ func (tc *TeleportClient) DeviceLogin(ctx context.Context, certs *devicepb.UserC
// getSSHLoginFunc returns an SSHLoginFunc that matches client and cluster settings.
func (tc *TeleportClient) getSSHLoginFunc(pr *webclient.PingResponse) (SSHLoginFunc, error) {
switch authType := pr.Auth.Type; {
case authType == constants.Local && pr.Auth.Local != nil && pr.Auth.Local.Name == constants.PasswordlessConnector:
// Sanity check settings.
if !pr.Auth.AllowPasswordless {
return nil, trace.BadParameter("passwordless disallowed by cluster settings")
switch pr.Auth.Type {
case constants.Local:
switch pr.Auth.Local.Name {
case constants.PasswordlessConnector:
// Sanity check settings.
if !pr.Auth.AllowPasswordless {
return nil, trace.BadParameter("passwordless disallowed by cluster settings")
}
return tc.pwdlessLogin, nil
case constants.HeadlessConnector:
// Sanity check settings.
if !pr.Auth.AllowHeadless {
return nil, trace.BadParameter("headless disallowed by cluster settings")
}
// TODO (Joerger): Add headless login flow.
fallthrough
case constants.LocalConnector, "":
// if passwordless is enabled and there are passwordless credentials
// registered, we can try to go with passwordless login even though
// auth=local was selected.
if tc.canDefaultToPasswordless(pr) {
log.Debug("Trying passwordless login because credentials were found")
return tc.pwdlessLogin, nil
}
return func(ctx context.Context, priv *keys.PrivateKey) (*auth.SSHLoginResponse, error) {
return tc.localLogin(ctx, priv, pr.Auth.SecondFactor)
}, nil
default:
return nil, trace.BadParameter("unsupported authentication connector type: %q", pr.Auth.Local.Name)
}
return tc.pwdlessLogin, nil
case authType == constants.Local && tc.canDefaultToPasswordless(pr):
log.Debug("Trying passwordless login because credentials were found")
// if passwordless is enabled and there are passwordless credentials
// registered, we can try to go with passwordless login even though
// auth=local was selected.
return tc.pwdlessLogin, nil
case authType == constants.Local:
return func(ctx context.Context, priv *keys.PrivateKey) (*auth.SSHLoginResponse, error) {
return tc.localLogin(ctx, priv, pr.Auth.SecondFactor)
}, nil
case authType == constants.OIDC:
case constants.OIDC:
return func(ctx context.Context, priv *keys.PrivateKey) (*auth.SSHLoginResponse, error) {
return tc.ssoLogin(ctx, priv, pr.Auth.OIDC.Name, constants.OIDC)
}, nil
case authType == constants.SAML:
case constants.SAML:
return func(ctx context.Context, priv *keys.PrivateKey) (*auth.SSHLoginResponse, error) {
return tc.ssoLogin(ctx, priv, pr.Auth.SAML.Name, constants.SAML)
}, nil
case authType == constants.Github:
case constants.Github:
return func(ctx context.Context, priv *keys.PrivateKey) (*auth.SSHLoginResponse, error) {
return tc.ssoLogin(ctx, priv, pr.Auth.Github.Name, constants.Github)
}, nil
+1
View File
@@ -790,6 +790,7 @@ SREzU8onbBsjMg9QDiSf5oJLKvd/Ren+zGY7
DisconnectExpiredCert: types.NewBoolOption(false),
LockingMode: constants.LockingModeBestEffort,
AllowPasswordless: types.NewBoolOption(true),
AllowHeadless: types.NewBoolOption(true),
IDP: &types.IdPOptions{
SAML: &types.IdPSAMLOptions{
Enabled: types.NewBoolOption(true),
+7
View File
@@ -1113,6 +1113,12 @@ type AuthenticationConfig struct {
// otherwise.
Passwordless *types.BoolOption `yaml:"passwordless"`
// Headless enables/disables headless support.
// Requires Webauthn to work.
// Defaults to true if the Webauthn is configured, defaults to false
// otherwise.
Headless *types.BoolOption `yaml:"headless"`
// DeviceTrust holds settings related to trusted device verification.
// Requires Teleport Enterprise.
DeviceTrust *DeviceTrust `yaml:"device_trust,omitempty"`
@@ -1156,6 +1162,7 @@ func (a *AuthenticationConfig) Parse() (types.AuthPreference, error) {
LockingMode: a.LockingMode,
AllowLocalAuth: a.LocalAuth,
AllowPasswordless: a.Passwordless,
AllowHeadless: a.Headless,
DeviceTrust: dt,
})
}
+23
View File
@@ -343,6 +343,29 @@ func TestAuthenticationSection(t *testing.T) {
Passwordless: types.NewBoolOption(true),
ConnectorName: "passwordless",
},
}, {
desc: "Local auth with headless connector",
mutate: func(cfg cfgMap) {
cfg["auth_service"].(cfgMap)["authentication"] = cfgMap{
"type": "local",
"second_factor": "on",
"webauthn": cfgMap{
"rp_id": "example.com",
},
"headless": "true",
"connector_name": "headless",
}
},
expectError: require.NoError,
expected: &AuthenticationConfig{
Type: "local",
SecondFactor: "on",
Webauthn: &Webauthn{
RPID: "example.com",
},
Headless: types.NewBoolOption(true),
ConnectorName: "headless",
},
}, {
desc: "Device Trust config",
mutate: func(cfg cfgMap) {
+1
View File
@@ -840,6 +840,7 @@ func localSettings(cap types.AuthPreference) (webclient.AuthenticationSettings,
SecondFactor: cap.GetSecondFactor(),
PreferredLocalMFA: cap.GetPreferredLocalMFA(),
AllowPasswordless: cap.GetAllowPasswordless(),
AllowHeadless: cap.GetAllowHeadless(),
Local: &webclient.LocalSettings{},
PrivateKeyPolicy: cap.GetPrivateKeyPolicy(),
DeviceTrustDisabled: deviceTrustDisabled(cap),
+16
View File
@@ -89,6 +89,22 @@ func TestPing(t *testing.T) {
assert.Equal(t, constants.PasswordlessConnector, resp.Auth.Local.Name, "Auth.Local.Name")
},
},
{
name: "OK headless connector",
spec: &types.AuthPreferenceSpecV2{
Type: constants.Local,
SecondFactor: constants.SecondFactorOptional,
Webauthn: &types.Webauthn{
RPID: "example.com",
},
ConnectorName: constants.HeadlessConnector,
},
assertResp: func(_ types.AuthPreference, resp *webclient.PingResponse) {
assert.True(t, resp.Auth.AllowHeadless, "Auth.AllowHeadless")
require.NotNil(t, resp.Auth.Local, "Auth.Local")
assert.Equal(t, constants.HeadlessConnector, resp.Auth.Local.Name, "Auth.Local.Name")
},
},
{
name: "OK device trust mode=off",
buildType: modules.BuildOSS,