mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-21 05:55:42 +08:00
Add headless auth preference logic. (#22148)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+33
-19
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user