[VAULT-38600] Create TOTP Login MFA credential self-enrollment API endpoint (#8970) (#8999)

Co-authored-by: Kuba Wieczorek <kuba.wieczorek@hashicorp.com>
This commit is contained in:
Vault Automation 2025-08-29 10:46:27 -06:00 committed by GitHub
parent eaf949cb1f
commit 5d632efcf3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 200 additions and 7 deletions

View File

@ -153,6 +153,19 @@ func (pq *PriorityQueue) PopByKey(key string) (*Item, error) {
return nil, nil
}
// PeekByKey returns the item with the given key without removing it from the queue.
func (pq *PriorityQueue) PeekByKey(id string) *Item {
pq.lock.RLock()
defer pq.lock.RUnlock()
item, ok := pq.dataMap[id]
if !ok {
return nil
}
return item
}
// Len returns the number of items in the queue data structure. Do not use this
// method directly on the queue, use PriorityQueue.Len() instead.
func (q queue) Len() int { return len(q) }

View File

@ -141,6 +141,67 @@ func TestPriorityQueue_Pop(t *testing.T) {
}
}
// TestPriorityQueue_PeekByKey tests the PeekByKey method of PriorityQueue.
// It verifies that PeekByKey returns the correct item without removing it from the queue,
// handles non-existing keys appropriately, works correctly on empty queues, and
// properly handles empty key strings.
func TestPriorityQueue_PeekByKey(t *testing.T) {
pq := New()
tc := testCases()
expectedLength := len(tc)
// Peek from empty queue
peekedItem := pq.PeekByKey("item-2")
if peekedItem != nil {
t.Fatal("expected nil when peeking from empty queue, got item")
}
if pq.Len() != 0 {
t.Fatalf("expected empty queue to remain size 0, got %d", pq.Len())
}
// Push test items
for _, item := range tc {
if err := pq.Push(item); err != nil {
t.Fatal(err)
}
}
// Peek with empty key
peekedItem = pq.PeekByKey("")
if peekedItem != nil {
t.Fatal("expected nil for empty key, got item")
}
// Verify queue size unchanged
if pq.Len() != expectedLength {
t.Fatalf("expected queue size to remain %d, got %d", expectedLength, pq.Len())
}
// Peek at non-existing item
peekedItem = pq.PeekByKey("non-existing-key")
if peekedItem != nil {
t.Fatal("expected nil for non-existing key, got item")
}
// Verify queue size unchanged
if pq.Len() != expectedLength {
t.Fatalf("expected queue size to remain %d, got %d", expectedLength, pq.Len())
}
// Peek at a specific item
peekedItem = pq.PeekByKey("item-2")
if peekedItem == nil {
t.Fatal("expected to peek item-2, got nil")
}
// Verify queue size unchanged
if pq.Len() != expectedLength {
t.Fatalf("expected queue size to remain %d, got %d", expectedLength, pq.Len())
}
// Verify item still exists in queue
stillExists := pq.PeekByKey("item-2")
if stillExists == nil {
t.Fatal("item should still exist after peek")
}
}
func TestPriorityQueue_PopByKey(t *testing.T) {
pq := New()

View File

@ -3993,14 +3993,34 @@ func (c *Core) loadLoginMFAConfigs(ctx context.Context) error {
return nil
}
// MFACachedAuthResponse represents an authentication response that has been
// temporarily cached during a two-phase MFA (Multi-Factor Authentication) login flow.
//
// This struct is used when an MFA enforcement is configured and a login request
// lacks MFA credentials. Instead of completing the authentication immediately,
// Vault caches the auth response and returns an MFARequirement to the client.
// The client must then complete MFA validation using the mfa/validate endpoint
// to retrieve the cached authentication and receive their token.
//
// The cached response includes the original authentication details along with
// request metadata needed for MFA validation, such as the client's IP address
// for methods like Duo that require connection information.
//
// This struct is also used to cache self-enrollment TOTP MFA secrets generated
// during login when self-enrollment is enabled. This allows Vault to avoid
// persisting the newly generated MFA secret until it has been successfully used
// for validating an MFA-enforced login request.
type MFACachedAuthResponse struct {
CachedAuth *logical.Auth
RequestPath string
RequestNSID string
RequestNSPath string
RequestConnRemoteAddr string
TimeOfStorage time.Time
RequestID string
CachedAuth *logical.Auth
RequestPath string
RequestNSID string
RequestNSPath string
RequestConnRemoteAddr string
TimeOfStorage time.Time
RequestID string
SelfEnrollmentMFASecret *mfa.Secret
// Store the secret key string separately to avoid anyone accidentally persisting it on an Entity.
SelfEnrollmentMFASecretKey string
}
func (c *Core) setupCachedMFAResponseAuth() {

View File

@ -165,6 +165,7 @@ func (i *IdentityStore) paths() []*framework.Path {
mfaDuoPaths(i),
mfaPingIDPaths(i),
mfaLoginEnforcementPaths(i),
mfaLoginEnterprisePaths(i),
)
}

View File

@ -4,6 +4,7 @@
package vault
import (
"errors"
"sync"
"time"
@ -71,6 +72,20 @@ func (pq *LoginMFAPriorityQueue) PopByKey(reqID string) (*MFACachedAuthResponse,
return item.Value.(*MFACachedAuthResponse), nil
}
// PeekByKey returns the item with the given key without removing it from the queue.
func (pq *LoginMFAPriorityQueue) PeekByKey(reqID string) (*MFACachedAuthResponse, error) {
pq.l.RLock()
defer pq.l.RUnlock()
item := pq.wrapped.PeekByKey(reqID)
if item == nil {
return nil, errors.New("no item found with the given request ID")
}
mfaResp := item.Value.(*MFACachedAuthResponse)
return mfaResp, nil
}
// RemoveExpiredMfaAuthResponse pops elements of the queue and check
// if the entry has expired or not. If the entry has not expired, it pushes
// back the entry to the queue. It returns false if there is no expired element

View File

@ -87,6 +87,89 @@ func TestLoginMFAPriorityQueue_PushPopByKey(t *testing.T) {
}
}
// TestLoginMFAPriorityQueue_PeekByKey tests the PeekByKey method of
// LoginMFAPriorityQueue. It verifies that PeekByKey returns the correct item
// without removing it from the queue, returns errors on unhappy paths, handles
// non-existing keys appropriately, works correctly on empty queues, and
// properly handles empty key strings.
func TestLoginMFAPriorityQueue_PeekByKey(t *testing.T) {
pq := NewLoginMFAPriorityQueue()
tc := testCases()
expectedLength := len(tc)
// Peek from empty queue
peekedItem, err := pq.PeekByKey("item-2")
if peekedItem != nil {
t.Fatal("expected nil when peeking from empty queue, got item")
}
if err == nil {
t.Fatal("expected an error when peeking from empty queue, got nil")
}
if pq.Len() != 0 {
t.Fatalf("expected empty queue to remain size 0, got %d", pq.Len())
}
// Push test items
for _, item := range tc {
if err := pq.Push(item); err != nil {
t.Fatal(err)
}
}
// Peek with empty key
peekedItem, err = pq.PeekByKey("")
if peekedItem != nil {
t.Fatal("expected nil for empty key, got item")
}
if err == nil {
t.Fatal("expected error when peeking with empty key , got nil")
}
// Verify queue size unchanged
if pq.Len() != expectedLength {
t.Fatalf("expected queue size to remain %d, got %d", expectedLength, pq.Len())
}
// Peek at non-existing item
peekedItem, err = pq.PeekByKey("non-existing-key")
if peekedItem != nil {
t.Fatal("expected nil for non-existing key, got item")
}
if err == nil {
t.Fatal("expected error when peeking with non-existing key, got nil")
}
// Verify queue size unchanged
if pq.Len() != expectedLength {
t.Fatalf("expected queue size to remain %d, got %d", expectedLength, pq.Len())
}
// Peek at a specific item
peekedItem, err = pq.PeekByKey(tc[2].RequestID)
if peekedItem == nil {
t.Fatal("expected to peek item-2, got nil")
}
if err != nil {
t.Fatal("expected no error when peeking existing key, got", err)
}
if peekedItem.RequestID != tc[2].RequestID {
t.Fatal("expected the same item on subsequent peeks, got different items")
}
// Verify queue size unchanged
if pq.Len() != expectedLength {
t.Fatalf("expected queue size to remain %d, got %d", expectedLength, pq.Len())
}
// Verify item still exists in queue
stillExists, err := pq.PeekByKey(tc[2].RequestID)
if stillExists == nil {
t.Fatal("item should still exist after peek")
}
if err != nil {
t.Fatal("expected no error when peeking existing key for the second time, got", err)
}
if stillExists.RequestID != tc[2].RequestID {
t.Fatal("expected the same item on subsequent peeks, got different items")
}
}
func TestLoginMFARemoveStaleEntries(t *testing.T) {
pq := NewLoginMFAPriorityQueue()