From ae0e5e160f186cc86b46f166e2b7026c4870673e Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Wed, 20 Aug 2025 14:22:00 -0600 Subject: [PATCH] [VAULT-38601] Modify response to MFA enforced requests to enable TOTP self-enrollment (#8723) (#8746) Co-authored-by: Kuba Wieczorek --- api/secret.go | 5 +++ sdk/logical/identity.pb.go | 84 ++++++++++++++++++++++---------------- sdk/logical/identity.proto | 1 + vault/login_mfa.go | 10 ++--- vault/login_mfa_test.go | 53 ++++++++++++++++++++---- vault/request_handling.go | 33 +++++++++++++-- 6 files changed, 134 insertions(+), 52 deletions(-) diff --git a/api/secret.go b/api/secret.go index b7165c7cce..9631136f89 100644 --- a/api/secret.go +++ b/api/secret.go @@ -298,6 +298,11 @@ type MFAMethodID struct { ID string `json:"id,omitempty"` UsesPasscode bool `json:"uses_passcode,omitempty"` Name string `json:"name,omitempty"` + // SelfEnrollmentEnabled indicates whether the user does not yet have an MFA + // secret for this method and self-enrollment is enabled for it. Clients (like the UI) can use + // this to determine whether to offer the user a way to generate an MFA secret + // for this method. + SelfEnrollmentEnabled bool `json:"self_enrollment_enabled,omitempty"` } type MFAConstraintAny struct { diff --git a/sdk/logical/identity.pb.go b/sdk/logical/identity.pb.go index 0bfc7050b8..ca3ccdb677 100644 --- a/sdk/logical/identity.pb.go +++ b/sdk/logical/identity.pb.go @@ -306,13 +306,14 @@ func (x *Group) GetNamespaceID() string { } type MFAMethodID struct { - state protoimpl.MessageState `protogen:"open.v1"` - Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` - ID string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` - UsesPasscode bool `protobuf:"varint,3,opt,name=uses_passcode,json=usesPasscode,proto3" json:"uses_passcode,omitempty"` - Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + ID string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` + UsesPasscode bool `protobuf:"varint,3,opt,name=uses_passcode,json=usesPasscode,proto3" json:"uses_passcode,omitempty"` + Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` + SelfEnrollmentEnabled bool `protobuf:"varint,5,opt,name=self_enrollment_enabled,json=selfEnrollmentEnabled,proto3" json:"self_enrollment_enabled,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *MFAMethodID) Reset() { @@ -373,6 +374,13 @@ func (x *MFAMethodID) GetName() string { return "" } +func (x *MFAMethodID) GetSelfEnrollmentEnabled() bool { + if x != nil { + return x.SelfEnrollmentEnabled + } + return false +} + type MFAConstraintAny struct { state protoimpl.MessageState `protogen:"open.v1"` Any []*MFAMethodID `protobuf:"bytes,1,rep,name=any,proto3" json:"any,omitempty"` @@ -531,35 +539,39 @@ var file_sdk_logical_identity_proto_rawDesc = string([]byte{ 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, - 0x01, 0x22, 0x6a, 0x0a, 0x0b, 0x4d, 0x46, 0x41, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x49, 0x44, - 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x74, 0x79, 0x70, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x02, 0x69, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x75, 0x73, 0x65, 0x73, 0x5f, 0x70, 0x61, 0x73, - 0x73, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x75, 0x73, 0x65, - 0x73, 0x50, 0x61, 0x73, 0x73, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, - 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x3a, 0x0a, - 0x10, 0x4d, 0x46, 0x41, 0x43, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x74, 0x41, 0x6e, - 0x79, 0x12, 0x26, 0x0a, 0x03, 0x61, 0x6e, 0x79, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, - 0x2e, 0x6c, 0x6f, 0x67, 0x69, 0x63, 0x61, 0x6c, 0x2e, 0x4d, 0x46, 0x41, 0x4d, 0x65, 0x74, 0x68, - 0x6f, 0x64, 0x49, 0x44, 0x52, 0x03, 0x61, 0x6e, 0x79, 0x22, 0xea, 0x01, 0x0a, 0x0e, 0x4d, 0x46, - 0x41, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0e, - 0x6d, 0x66, 0x61, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6d, 0x66, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x49, 0x64, 0x12, 0x54, 0x0a, 0x0f, 0x6d, 0x66, 0x61, 0x5f, 0x63, 0x6f, 0x6e, 0x73, 0x74, 0x72, - 0x61, 0x69, 0x6e, 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x6c, 0x6f, - 0x67, 0x69, 0x63, 0x61, 0x6c, 0x2e, 0x4d, 0x46, 0x41, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x66, 0x61, 0x43, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, - 0x6e, 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0e, 0x6d, 0x66, 0x61, 0x43, 0x6f, 0x6e, - 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x74, 0x73, 0x1a, 0x5c, 0x0a, 0x13, 0x4d, 0x66, 0x61, 0x43, - 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, - 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, - 0x79, 0x12, 0x2f, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x19, 0x2e, 0x6c, 0x6f, 0x67, 0x69, 0x63, 0x61, 0x6c, 0x2e, 0x4d, 0x46, 0x41, 0x43, 0x6f, - 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x74, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x28, 0x5a, 0x26, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, - 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x73, 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x76, - 0x61, 0x75, 0x6c, 0x74, 0x2f, 0x73, 0x64, 0x6b, 0x2f, 0x6c, 0x6f, 0x67, 0x69, 0x63, 0x61, 0x6c, - 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x01, 0x22, 0xa2, 0x01, 0x0a, 0x0b, 0x4d, 0x46, 0x41, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x49, + 0x44, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x75, 0x73, 0x65, 0x73, 0x5f, 0x70, 0x61, + 0x73, 0x73, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x75, 0x73, + 0x65, 0x73, 0x50, 0x61, 0x73, 0x73, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x36, + 0x0a, 0x17, 0x73, 0x65, 0x6c, 0x66, 0x5f, 0x65, 0x6e, 0x72, 0x6f, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, + 0x74, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x15, 0x73, 0x65, 0x6c, 0x66, 0x45, 0x6e, 0x72, 0x6f, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x45, + 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x3a, 0x0a, 0x10, 0x4d, 0x46, 0x41, 0x43, 0x6f, 0x6e, + 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x74, 0x41, 0x6e, 0x79, 0x12, 0x26, 0x0a, 0x03, 0x61, 0x6e, + 0x79, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6c, 0x6f, 0x67, 0x69, 0x63, 0x61, + 0x6c, 0x2e, 0x4d, 0x46, 0x41, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x49, 0x44, 0x52, 0x03, 0x61, + 0x6e, 0x79, 0x22, 0xea, 0x01, 0x0a, 0x0e, 0x4d, 0x46, 0x41, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0e, 0x6d, 0x66, 0x61, 0x5f, 0x72, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6d, + 0x66, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x54, 0x0a, 0x0f, 0x6d, + 0x66, 0x61, 0x5f, 0x63, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x74, 0x73, 0x18, 0x02, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x6c, 0x6f, 0x67, 0x69, 0x63, 0x61, 0x6c, 0x2e, 0x4d, + 0x46, 0x41, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x66, + 0x61, 0x43, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x52, 0x0e, 0x6d, 0x66, 0x61, 0x43, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x74, + 0x73, 0x1a, 0x5c, 0x0a, 0x13, 0x4d, 0x66, 0x61, 0x43, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, + 0x6e, 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2f, 0x0a, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6c, 0x6f, 0x67, 0x69, + 0x63, 0x61, 0x6c, 0x2e, 0x4d, 0x46, 0x41, 0x43, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, + 0x74, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, + 0x28, 0x5a, 0x26, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, + 0x73, 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x2f, 0x73, 0x64, + 0x6b, 0x2f, 0x6c, 0x6f, 0x67, 0x69, 0x63, 0x61, 0x6c, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x33, }) var ( diff --git a/sdk/logical/identity.proto b/sdk/logical/identity.proto index 8bac555901..9112bec30d 100644 --- a/sdk/logical/identity.proto +++ b/sdk/logical/identity.proto @@ -83,6 +83,7 @@ message MFAMethodID { string id = 2; bool uses_passcode = 3; string name = 4; + bool self_enrollment_enabled = 5; } message MFAConstraintAny { diff --git a/vault/login_mfa.go b/vault/login_mfa.go index 4e681678e6..f7197b9b4c 100644 --- a/vault/login_mfa.go +++ b/vault/login_mfa.go @@ -1273,7 +1273,7 @@ func (b *LoginMFABackend) mfaConfigReadByMethodID(id string) (map[string]interfa return nil, nil } - return b.mfaConfigToMap(mConfig, true) + return b.mfaConfigToMap(mConfig, constants.IsEnterprise, true) } func (b *LoginMFABackend) mfaMethodList(ctx context.Context, methodType string) ([]string, map[string]interface{}, error) { @@ -1333,7 +1333,7 @@ func (b *LoginMFABackend) mfaMethodList(ctx context.Context, methodType string) } keys = append(keys, config.ID) - configInfoEntry, err := b.mfaConfigToMap(config, true) + configInfoEntry, err := b.mfaConfigToMap(config, constants.IsEnterprise, true) if err != nil { return nil, nil, fmt.Errorf("failed to convert config to map: %w", err) } @@ -1423,7 +1423,7 @@ func (b *LoginMFABackend) mfaLoginEnforcementConfigToMap(eConfig *mfa.MFAEnforce // method endpoints. The `isLoginMFA` parameter indicates whether the // configuration is for login MFA, which includes additional fields on the // shared mfa.Config object for the TOTP type MFA method. -func (b *MFABackend) mfaConfigToMap(mConfig *mfa.Config, isLoginMFA bool) (map[string]interface{}, error) { +func (b *MFABackend) mfaConfigToMap(mConfig *mfa.Config, isEnterprise, isLoginMFA bool) (map[string]interface{}, error) { respData := make(map[string]interface{}) switch mConfig.Config.(type) { @@ -1437,9 +1437,9 @@ func (b *MFABackend) mfaConfigToMap(mConfig *mfa.Config, isLoginMFA bool) (map[s respData["qr_size"] = totpConfig.QRSize respData["algorithm"] = otplib.Algorithm(totpConfig.Algorithm).String() respData["max_validation_attempts"] = totpConfig.MaxValidationAttempts - if isLoginMFA { + if isEnterprise && isLoginMFA { // Login MFA and policy (i.e. enterprise step-up) MFA share the same protobuf message for TOTPConfig, - // but the login MFA has an additional field for self-enrollment. + // but the login MFA has an additional field for self-enrollment, which is an enterprise feature. respData["enable_self_enrollment"] = totpConfig.GetEnableSelfEnrollment() } case *mfa.Config_OktaConfig: diff --git a/vault/login_mfa_test.go b/vault/login_mfa_test.go index 075ff55c96..36d319b6ab 100644 --- a/vault/login_mfa_test.go +++ b/vault/login_mfa_test.go @@ -79,6 +79,7 @@ func TestMFAConfigToMap(t *testing.T) { testCases := map[string]struct { config *mfa.Config expectedResult map[string]any + isEnterprise bool isLoginMFA bool }{ "totp-with-login-mfa": { @@ -114,7 +115,43 @@ func TestMFAConfigToMap(t *testing.T) { "skew": uint32(1), "type": "totp", }, - isLoginMFA: true, + isEnterprise: true, + isLoginMFA: true, + }, + "totp-with-login-mfa-non-ent": { + config: &mfa.Config{ + Type: mfaMethodTypeTOTP, + Config: &mfa.Config_TOTPConfig{ + TOTPConfig: &mfa.TOTPConfig{ + Issuer: "TestIssuer", + Period: 30, + Digits: 6, + Skew: 1, + KeySize: 20, + QRSize: 200, + Algorithm: int32(otplib.AlgorithmSHA1), + MaxValidationAttempts: 5, + EnableSelfEnrollment: true, + }, + }, + }, + expectedResult: map[string]interface{}{ + "algorithm": "SHA1", + "digits": int32(6), + "id": "", + "issuer": "TestIssuer", + "key_size": uint32(20), + "max_validation_attempts": uint32(5), + "name": "", + "namespace_id": "", + "namespace_path": "/", + "period": uint32(30), + "qr_size": int32(200), + "skew": uint32(1), + "type": "totp", + }, + isEnterprise: false, + isLoginMFA: true, }, "totp-ent-step-up-mfa-self-enrollment-false": { config: &mfa.Config{ @@ -148,7 +185,8 @@ func TestMFAConfigToMap(t *testing.T) { "skew": uint32(1), "type": "totp", }, - isLoginMFA: false, + isEnterprise: true, + isLoginMFA: false, }, "totp-ent-step-up-mfa-self-enrollment-true": { config: &mfa.Config{ @@ -182,7 +220,8 @@ func TestMFAConfigToMap(t *testing.T) { "skew": uint32(1), "type": "totp", }, - isLoginMFA: false, + isEnterprise: true, + isLoginMFA: false, }, "okta-prod": { config: &mfa.Config{ @@ -257,9 +296,8 @@ func TestMFAConfigToMap(t *testing.T) { "namespace_id": "", "namespace_path": "/", }, - isLoginMFA: false, }, - "pingid": { + "ping-id": { config: &mfa.Config{ Type: mfaMethodTypePingID, Config: &mfa.Config_PingIDConfig{ @@ -284,7 +322,6 @@ func TestMFAConfigToMap(t *testing.T) { "namespace_id": "", "namespace_path": "/", }, - isLoginMFA: false, }, } backend := &MFABackend{} @@ -292,7 +329,7 @@ func TestMFAConfigToMap(t *testing.T) { for name, tc := range testCases { t.Run(name, func(t *testing.T) { - actualResult, err := backend.mfaConfigToMap(tc.config, tc.isLoginMFA) + actualResult, err := backend.mfaConfigToMap(tc.config, tc.isEnterprise, tc.isLoginMFA) require.NoError(t, err) require.Equal(t, tc.expectedResult, actualResult) }) @@ -308,7 +345,7 @@ func TestMfaConfigToMap_InvalidType(t *testing.T) { Config: nil, } - _, err := backend.mfaConfigToMap(mConfig, true) + _, err := backend.mfaConfigToMap(mConfig, true, true) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid method type") } diff --git a/vault/request_handling.go b/vault/request_handling.go index 5c7a5da0d7..2d14e4f0f9 100644 --- a/vault/request_handling.go +++ b/vault/request_handling.go @@ -26,6 +26,7 @@ import ( "github.com/hashicorp/go-sockaddr" "github.com/hashicorp/go-uuid" "github.com/hashicorp/vault/command/server" + "github.com/hashicorp/vault/helper/constants" "github.com/hashicorp/vault/helper/identity" "github.com/hashicorp/vault/helper/identity/mfa" "github.com/hashicorp/vault/helper/metricsutil" @@ -1874,7 +1875,7 @@ func (c *Core) handleLoginRequest(ctx context.Context, req *logical.Request) (re MFAConstraints: make(map[string]*logical.MFAConstraintAny), } for _, eConfig := range matchedMfaEnforcementList { - mfaAny, err := c.buildMfaEnforcementResponse(eConfig) + mfaAny, err := c.buildMfaEnforcementResponse(eConfig, entity) if err != nil { return nil, nil, err } @@ -2456,7 +2457,13 @@ func (c *Core) getUserLockoutFromConfig(mountType string) UserLockoutConfig { return defaultUserLockoutConfig } -func (c *Core) buildMfaEnforcementResponse(eConfig *mfa.MFAEnforcementConfig) (*logical.MFAConstraintAny, error) { +func (c *Core) buildMfaEnforcementResponse(eConfig *mfa.MFAEnforcementConfig, entity *identity.Entity) (*logical.MFAConstraintAny, error) { + if eConfig == nil { + return nil, fmt.Errorf("MFA enforcement config is nil") + } + if entity == nil { + return nil, fmt.Errorf("entity is nil") + } mfaAny := &logical.MFAConstraintAny{ Any: []*logical.MFAMethodID{}, } @@ -2469,15 +2476,35 @@ func (c *Core) buildMfaEnforcementResponse(eConfig *mfa.MFAEnforcementConfig) (* if mConfig.Type == mfaMethodTypeDuo { duoConf, ok := mConfig.Config.(*mfa.Config_DuoConfig) if !ok { - return nil, fmt.Errorf("invalid MFA configuration type") + return nil, fmt.Errorf("invalid MFA configuration type, expected DuoConfig") } duoUsePasscode = duoConf.DuoConfig.UsePasscode } + + allowSelfEnrollment := false + if mConfig.Type == mfaMethodTypeTOTP && constants.IsEnterprise { + totpConf, ok := mConfig.Config.(*mfa.Config_TOTPConfig) + if !ok { + return nil, fmt.Errorf("invalid MFA configuration type, expected TOTPConfig") + } + enrollmentEnabled := totpConf.TOTPConfig.GetEnableSelfEnrollment() + _, entityHasMFASecretForMethodID := entity.MFASecrets[methodID] + if enrollmentEnabled && !entityHasMFASecretForMethodID { + // If enable_self_enrollment setting on the TOTP MFA method config is set to + // true and the entity does not have an MFA secret yet, we will allow + // self-service enrollment. + allowSelfEnrollment = true + } + } + mfaMethod := &logical.MFAMethodID{ Type: mConfig.Type, ID: methodID, UsesPasscode: mConfig.Type == mfaMethodTypeTOTP || duoUsePasscode, Name: mConfig.Name, + // This will be used by the client to determine whether it should offer the user + // a way to generate an MFA secret for this method. + SelfEnrollmentEnabled: allowSelfEnrollment, } mfaAny.Any = append(mfaAny.Any, mfaMethod) }