mirror of
				https://github.com/teamhanko/hanko.git
				synced 2025-11-01 00:58:16 +08:00 
			
		
		
		
	 bc04b729dd
			
		
	
	bc04b729dd
	
	
	
		
			
			* feat: create otp_secrets table * feat: create otp secret model * feat: add mfa_only column to webauthn_credentials table * feat: add mfa only field to webauthn credential model * feat: add mfa config (#1607) * feat: add otp secret persister (#1613) * feat: MFA usage sub flow (#1614) * feat: add mfa-usage sub-flow --------- Co-authored-by: Lennart Fleischmann <67686424+lfleischmann@users.noreply.github.com> * feat: include platform authenticator availybility in the preflight flow (#1615) * feat: add mfa creation subflow * feat: adjust registration flow * feat: integrate mfa usage sub-flow * feat: add pages for mfa (#1622) * feat: profile flow adjustments for mfa support * fix: suspension logic for mfa deletion actions * feat: use dedicated action for security key creation options * fix: mfa method stash entry can be stale on profile flow The mfa_creation subflow sets an mfa_method stash value so that when creating and persisting the credential the mfa_only flag can be set correctly in the hook responsible for that. But the profile flow never "ends" and and returns to the initial state so I can also register a passkey afterwards. The mfa_method stash key remains on the stash but is used in the hook nonetheless, so the passkey is incorrectly recognized as a security key. The mfa_method key is now deleted after successfully persisting the credential/security_key. This should not have an effect on the login flow because the mfa_creation subflow is the last subflow to be executed. It also should not affect the registration flow, because the hook is not applied in the registration flow (persistence of data is all handled in the create_user hook). * feat: add new icons and english translations (#1626) * fix: credential id encoding corrected (#1628) * feat: add audit logs for mfa creation * feat: add a skip link to the mfa method chooser (#1630) * feat: save the security key during login (#1629) * feat: show security keys in profile * feat: add authenticator app management to profile (#1633) * feat: add authenticator app management to profile * feat: passkey counts as second factor * feat: prohibit security key first factor usage * feat: add all WA creds to exclude list on registration * refactor: mfa stash entries and webauthn credential persistence Renames MFA stash entry for indicating usage (login) method to make its meaning more explicit. Also removes code persisting a webauthn credential from the attestation verification action in the onboarding flow because this is already done by a shared hook. * refactor: simplify WA creation call Co-authored-by: bjoern-m <56024829+bjoern-m@users.noreply.github.com> * chore: adjust mfa flow * fix: mfa onboarding always shown during login * fix: mfa onboarding not shown after password or email creation during login * fix: mfa onboarding not shown without user detail onboarding * fix: correct skip/back behaviour * feat: reuse generated otp secret when the code is invalid * chore: skip mfa prompt if the user only has a passkey * chore: adjust login flow * chore: skip mfa prompt if the user only has a passkey * chore: refactor and improve mfa onboarding * fix: no mfa onboarding when passwords and passkeys are disabled * fix: only show mfa onbooarding once * feat: add a function to the flowpilot to check whether a state has been visited * chore: adjust recovery flow (#1655) * feat: disable password, passcode endpoints when mfa enabled * Feat: remember last used login method (#1674) * chore: remove omitempty from boolean (#1676) * chore: improved error handling (#1679) * chore: improved error handling * feat: add missing translations (#1681) * feat: update aaguid list (#1678) * fix: do not suspend webauthn action for MFA (#1778) Do not suspend the `webauthn_verify_attestation_response` action when passkeys are disabled, but security keys and MFA are enabled. * fix: change texts (#1785) Change texts regarding security creation to be more consistent across the flows and to be more precise. * Fix: UI issues (#1846) * fix: loading spinner alignment corrected * fix: auth app deletion link is shown while deletion is not allowed * Chore: remove test persister (#1876) * chore: remove deprecated test persister * chore: replace test persister calls * chore: add saml state fixtures * Update backend/flow_api/services/webauthn.go Co-authored-by: Frederic Jahn <frederic.jahn@hanko.io> * Update backend/dto/profile.go Co-authored-by: Frederic Jahn <frederic.jahn@hanko.io> * fix: otp validation uses the rate limiter key for passwords * chore: add otp-limits to the default config * chore: add explanation for 'UserVerification' setting on security keys --------- Co-authored-by: Lennart Fleischmann <lennart.fleischmann@hanko.io> Co-authored-by: Lennart Fleischmann <67686424+lfleischmann@users.noreply.github.com> Co-authored-by: Frederic Jahn <frederic.jahn@hanko.io>
		
			
				
	
	
		
			334 lines
		
	
	
		
			9.0 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			334 lines
		
	
	
		
			9.0 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| package flowpilot
 | |
| 
 | |
| import (
 | |
| 	"bytes"
 | |
| 	"compress/gzip"
 | |
| 	"encoding/base64"
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 	"github.com/teamhanko/hanko/backend/flowpilot/jsonmanager"
 | |
| 	"github.com/tidwall/gjson"
 | |
| 	"github.com/tidwall/sjson"
 | |
| 	"io"
 | |
| )
 | |
| 
 | |
| const (
 | |
| 	stashKeyState           = "state"
 | |
| 	stashKeyPreviousState   = "prev_state"
 | |
| 	stashKeyScheduledStates = "scheduled"
 | |
| 	stashKeyData            = "data"
 | |
| 	stashKeyHistory         = "hist"
 | |
| 	stashKeyRevertible      = "revertible"
 | |
| 	stashKeySticky          = "sticky"
 | |
| )
 | |
| 
 | |
| type stash interface {
 | |
| 	pushState(bool) error
 | |
| 	pushErrorState(StateName) error
 | |
| 	revertState() error
 | |
| 	isRevertible() bool
 | |
| 	getStateName() StateName
 | |
| 	getPreviousStateName() StateName
 | |
| 	getNextStateName() StateName
 | |
| 	addScheduledStateNames(...StateName)
 | |
| 	getScheduledStateNames() []StateName
 | |
| 	useCompression(bool)
 | |
| 	stateVisited(name StateName) bool
 | |
| 
 | |
| 	jsonmanager.JSONManager
 | |
| }
 | |
| 
 | |
| type defaultStash struct {
 | |
| 	jm                  jsonmanager.JSONManager
 | |
| 	data                jsonmanager.JSONManager
 | |
| 	scheduledStateNames []StateName
 | |
| 	compressionEnabled  bool
 | |
| }
 | |
| 
 | |
| // newStashFromJSONManager creates a new instance of stash with a given JSONManager.
 | |
| func newStashFromJSONManager(jm jsonmanager.JSONManager) stash {
 | |
| 	data, _ := jsonmanager.NewJSONManagerFromString(jm.Get(stashKeyData).String())
 | |
| 	return &defaultStash{
 | |
| 		jm:                  jm,
 | |
| 		data:                data,
 | |
| 		scheduledStateNames: make([]StateName, 0),
 | |
| 		compressionEnabled:  false,
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // newStash creates a new instance of Stash with empty JSON data.
 | |
| func newStash(nextStates ...StateName) (stash, error) {
 | |
| 	jm := jsonmanager.NewJSONManager()
 | |
| 
 | |
| 	if len(nextStates) == 0 {
 | |
| 		return nil, errors.New("can't create a new stash without a state name")
 | |
| 	}
 | |
| 
 | |
| 	if err := jm.Set(stashKeyState, nextStates[0]); err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	if err := jm.Set(stashKeyScheduledStates, reverseStateNames(nextStates[1:])); err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	if err := jm.Set(stashKeyData, "{}"); err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	return newStashFromJSONManager(jm), nil
 | |
| }
 | |
| 
 | |
| // newStashFromString creates a new instance of Stash with the given JSON data.
 | |
| func newStashFromString(data string) (stash, error) {
 | |
| 	var err error
 | |
| 
 | |
| 	if len(data) > 0 && !startsWithCurlyBrace(data) {
 | |
| 		if data, err = decodeData(data); err != nil {
 | |
| 			return nil, fmt.Errorf("faiiled to decode stash data: %w", err)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	jm, err := jsonmanager.NewJSONManagerFromString(data)
 | |
| 	return newStashFromJSONManager(jm), err
 | |
| }
 | |
| 
 | |
| func reverseStateNames(slice []StateName) []StateName {
 | |
| 	reversed := make([]StateName, len(slice))
 | |
| 	for i, v := range slice {
 | |
| 		reversed[len(slice)-1-i] = v
 | |
| 	}
 | |
| 	return reversed
 | |
| }
 | |
| 
 | |
| func startsWithCurlyBrace(s string) bool {
 | |
| 	// Check if the string is not empty
 | |
| 	if len(s) == 0 {
 | |
| 		return false
 | |
| 	}
 | |
| 	// Check if the first character is '{'
 | |
| 	return s[0] == '{'
 | |
| }
 | |
| 
 | |
| func encodeData(jsonData string) (string, error) {
 | |
| 	var buf bytes.Buffer
 | |
| 	gw := gzip.NewWriter(&buf)
 | |
| 	if _, err := gw.Write([]byte(jsonData)); err != nil {
 | |
| 		return "", err
 | |
| 	}
 | |
| 
 | |
| 	if err := gw.Close(); err != nil {
 | |
| 		return "", err
 | |
| 	}
 | |
| 
 | |
| 	gzippedData := buf.Bytes()
 | |
| 	base64GzippedData := base64.StdEncoding.EncodeToString(gzippedData)
 | |
| 	return base64GzippedData, nil
 | |
| }
 | |
| 
 | |
| func decodeData(base64GzippedData string) (string, error) {
 | |
| 	gzippedData, err := base64.StdEncoding.DecodeString(base64GzippedData)
 | |
| 	if err != nil {
 | |
| 		return "", err
 | |
| 	}
 | |
| 
 | |
| 	buf := bytes.NewBuffer(gzippedData)
 | |
| 	gr, err := gzip.NewReader(buf)
 | |
| 	if err != nil {
 | |
| 		return "", err
 | |
| 	}
 | |
| 
 | |
| 	defer gr.Close()
 | |
| 
 | |
| 	decompressedData, err := io.ReadAll(gr)
 | |
| 	if err != nil {
 | |
| 		return "", err
 | |
| 	}
 | |
| 
 | |
| 	return string(decompressedData), nil
 | |
| }
 | |
| 
 | |
| // Get retrieves the value at the specified path in the JSON data.
 | |
| func (h *defaultStash) Get(path string) gjson.Result {
 | |
| 	return h.data.Get(path)
 | |
| }
 | |
| 
 | |
| // Set updates the JSON data at the specified path with the provided value.
 | |
| func (h *defaultStash) Set(path string, value interface{}) error {
 | |
| 	return h.data.Set(path, value)
 | |
| }
 | |
| 
 | |
| // Delete removes a value from the JSON data at the specified path.
 | |
| func (h *defaultStash) Delete(path string) error {
 | |
| 	return h.data.Delete(path)
 | |
| }
 | |
| 
 | |
| // String returns the JSON data as a string.
 | |
| func (h *defaultStash) String() string {
 | |
| 	if h.compressionEnabled {
 | |
| 		s, _ := encodeData(h.jm.String())
 | |
| 		return s
 | |
| 	}
 | |
| 	return h.jm.String()
 | |
| }
 | |
| 
 | |
| // Unmarshal parses the JSON data and returns it as an interface{}.
 | |
| func (h *defaultStash) Unmarshal() interface{} {
 | |
| 	return h.jm.Unmarshal()
 | |
| }
 | |
| 
 | |
| func (h *defaultStash) pushState(revertible bool) error {
 | |
| 	return h.push(h.data.String(), revertible, h.getStateName() != h.getNextStateName())
 | |
| }
 | |
| 
 | |
| func (h *defaultStash) pushErrorState(nextState StateName) error {
 | |
| 	return h.push(h.jm.Get(stashKeyData).String(), h.isRevertible(), false, nextState)
 | |
| }
 | |
| 
 | |
| func (h *defaultStash) push(newData string, revertible, writeHistory bool, nextStates ...StateName) error {
 | |
| 	var err error
 | |
| 
 | |
| 	data := h.jm.Get(stashKeyData)
 | |
| 	scheduledStates := h.jm.Get(stashKeyScheduledStates)
 | |
| 	scheduledStatesArr := scheduledStates.Array()
 | |
| 	stateStr := h.jm.Get(stashKeyState).String()
 | |
| 	prevStateStr := h.jm.Get(stashKeyPreviousState).String()
 | |
| 
 | |
| 	scheduledStatesUpdated := make([]StateName, len(scheduledStatesArr))
 | |
| 	maxIndex := len(scheduledStatesUpdated) - 1
 | |
| 	for index := range scheduledStatesUpdated {
 | |
| 		scheduledStatesUpdated[maxIndex-index] = StateName(scheduledStatesArr[index].String())
 | |
| 	}
 | |
| 
 | |
| 	scheduledStatesUpdated = append(nextStates, append(h.scheduledStateNames, scheduledStatesUpdated...)...)
 | |
| 	if len(scheduledStatesUpdated) == 0 {
 | |
| 		return errors.New("no state left to be used as the next state")
 | |
| 	}
 | |
| 
 | |
| 	nextStateName := scheduledStatesUpdated[0]
 | |
| 	scheduledStatesUpdated = reverseStateNames(scheduledStatesUpdated[1:])
 | |
| 
 | |
| 	if writeHistory {
 | |
| 		histItem := "{}"
 | |
| 		for key, value := range map[string]interface{}{
 | |
| 			stashKeyState:           stateStr,
 | |
| 			stashKeyPreviousState:   prevStateStr,
 | |
| 			stashKeyData:            data.Value(),
 | |
| 			stashKeyRevertible:      revertible,
 | |
| 			stashKeyScheduledStates: scheduledStates.Value(),
 | |
| 		} {
 | |
| 			if histItem, err = sjson.Set(histItem, key, value); err != nil {
 | |
| 				return err
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		stashKeyNewHistItem := fmt.Sprintf("%s.-1", stashKeyHistory)
 | |
| 		if err = h.jm.Set(stashKeyNewHistItem, gjson.Parse(histItem).Value()); err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	for key, value := range map[string]interface{}{
 | |
| 		stashKeyState:           nextStateName,
 | |
| 		stashKeyPreviousState:   stateStr,
 | |
| 		stashKeyData:            gjson.Parse(newData).Value(),
 | |
| 		stashKeyScheduledStates: scheduledStatesUpdated,
 | |
| 	} {
 | |
| 		if err = h.jm.Set(key, value); err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func (h *defaultStash) stateVisited(name StateName) bool {
 | |
| 	visited := false
 | |
| 	h.jm.Get(stashKeyHistory).ForEach(func(key, value gjson.Result) bool {
 | |
| 		if StateName(value.Get(stashKeyState).String()) == name {
 | |
| 			visited = true
 | |
| 			return false
 | |
| 		}
 | |
| 		return true
 | |
| 	})
 | |
| 	return visited
 | |
| }
 | |
| 
 | |
| func (h *defaultStash) revertState() error {
 | |
| 	var err error
 | |
| 
 | |
| 	lastHistItemIndex := h.jm.Get(fmt.Sprintf("%s.#", stashKeyHistory)).Int() - 1
 | |
| 	lastHistItem := h.jm.Get(fmt.Sprintf("%s.%d", stashKeyHistory, lastHistItemIndex))
 | |
| 
 | |
| 	if !lastHistItem.Exists() {
 | |
| 		return errors.New("no state to revert to")
 | |
| 	}
 | |
| 
 | |
| 	if !lastHistItem.Get(stashKeyRevertible).Bool() {
 | |
| 		return errors.New("state is not revertible")
 | |
| 	}
 | |
| 
 | |
| 	dataUpdated := lastHistItem.Get(stashKeyData)
 | |
| 	h.data.Get(stashKeySticky).ForEach(func(key, value gjson.Result) bool {
 | |
| 		path := fmt.Sprintf("%s.%s", stashKeySticky, key.String())
 | |
| 		updated, _ := sjson.Set(dataUpdated.String(), path, value.Value())
 | |
| 		dataUpdated = gjson.Parse(updated)
 | |
| 		return true
 | |
| 	})
 | |
| 
 | |
| 	if err = h.jm.Delete(fmt.Sprintf("%s.-1", stashKeyHistory)); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	for key, value := range map[string]interface{}{
 | |
| 		stashKeyScheduledStates: lastHistItem.Get(stashKeyScheduledStates).Value(),
 | |
| 		stashKeyState:           lastHistItem.Get(stashKeyState).Value(),
 | |
| 		stashKeyPreviousState:   lastHistItem.Get(stashKeyPreviousState).Value(),
 | |
| 		stashKeyData:            dataUpdated.Value(),
 | |
| 	} {
 | |
| 		if err = h.jm.Set(key, value); err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func (h *defaultStash) getStateName() StateName {
 | |
| 	return StateName(h.jm.Get(stashKeyState).String())
 | |
| }
 | |
| 
 | |
| func (h *defaultStash) getPreviousStateName() StateName {
 | |
| 	return StateName(h.jm.Get(stashKeyPreviousState).String())
 | |
| }
 | |
| 
 | |
| func (h *defaultStash) addScheduledStateNames(names ...StateName) {
 | |
| 	h.scheduledStateNames = append(h.scheduledStateNames, names...)
 | |
| }
 | |
| func (h *defaultStash) getScheduledStateNames() []StateName {
 | |
| 	values := h.jm.Get(stashKeyScheduledStates).Array()
 | |
| 	result := make([]StateName, len(values))
 | |
| 	for i, value := range values {
 | |
| 		result[i] = StateName(value.String())
 | |
| 	}
 | |
| 	return result
 | |
| }
 | |
| 
 | |
| func (h *defaultStash) getNextStateName() StateName {
 | |
| 	if len(h.scheduledStateNames) > 0 {
 | |
| 		return h.scheduledStateNames[0]
 | |
| 	}
 | |
| 
 | |
| 	lastScheduledIndex := h.jm.Get(fmt.Sprintf("%s.#", stashKeyScheduledStates)).Int() - 1
 | |
| 	return StateName(h.jm.Get(fmt.Sprintf("%s.%d", stashKeyScheduledStates, lastScheduledIndex)).String())
 | |
| }
 | |
| 
 | |
| func (h *defaultStash) isRevertible() bool {
 | |
| 	lastHistItemIndex := h.jm.Get(fmt.Sprintf("%s.#", stashKeyHistory)).Int() - 1
 | |
| 	return h.jm.Get(fmt.Sprintf("%s.%d.%s", stashKeyHistory, lastHistItemIndex, stashKeyRevertible)).Bool()
 | |
| }
 | |
| 
 | |
| func (h *defaultStash) useCompression(b bool) {
 | |
| 	h.compressionEnabled = b
 | |
| }
 |