mirror of
https://github.com/teamhanko/hanko.git
synced 2025-10-29 15:49:41 +08:00
* 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>
275 lines
7.9 KiB
Go
275 lines
7.9 KiB
Go
package flowpilot
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"github.com/gofrs/uuid"
|
|
"time"
|
|
)
|
|
|
|
type context interface {
|
|
// Get returns the context value with the given name.
|
|
Get(string) interface{}
|
|
GetFlowName() FlowName
|
|
// IsFlow returns true if the name matches the current flow name.
|
|
IsFlow(name FlowName) bool
|
|
}
|
|
|
|
// flowContext represents the basic context for a flow.
|
|
type flowContext interface {
|
|
// Set sets a context value for the given key.
|
|
Set(string, interface{})
|
|
// GetFlowID returns the unique ID of the current defaultFlow.
|
|
GetFlowID() uuid.UUID
|
|
// Payload returns the JSONManager for accessing payload data.
|
|
Payload() payload
|
|
// Stash returns the JSONManager for accessing stash data.
|
|
Stash() stash
|
|
// GetInitialState returns the initial state of the flow.
|
|
GetInitialState() StateName
|
|
// GetCurrentState returns the current state of the flow.
|
|
GetCurrentState() StateName
|
|
IsStateScheduled(StateName) bool
|
|
StateVisited(name StateName) bool
|
|
GetScheduledStates() []StateName
|
|
// GetPreviousState returns the previous state of the flow.
|
|
GetPreviousState() StateName
|
|
// IsPreviousState returns true if the previous state equals the given name.
|
|
IsPreviousState(name StateName) bool
|
|
// GetErrorState returns the designated error state of the flow.
|
|
GetErrorState() StateName
|
|
}
|
|
|
|
// actionInitializationContext represents the basic context for a flow action's initialization.
|
|
type actionInitializationContext interface {
|
|
// AddInputs adds input parameters to the inputSchema.
|
|
AddInputs(inputs ...Input)
|
|
StateIsRevertible() bool
|
|
|
|
flowContext
|
|
actionSuspender
|
|
}
|
|
|
|
// actionExecutionContext represents the context for an action execution.
|
|
type actionExecutionContext interface {
|
|
// Input returns the executionInputSchema for the action.
|
|
Input() executionInputSchema
|
|
// ValidateInputData validates the input data against the inputSchema.
|
|
ValidateInputData() bool
|
|
// CopyInputValuesToStash copies specified inputs to the stash.
|
|
CopyInputValuesToStash(inputNames ...string) error
|
|
SetFlowError(FlowError)
|
|
PreventRevert()
|
|
ExecuteHook(HookAction) error
|
|
actionSuspender
|
|
flowContext
|
|
}
|
|
|
|
// actionExecutionContinuationContext represents the context within an action continuation.
|
|
type actionExecutionContinuationContext interface {
|
|
Continue(stateNames ...StateName) error
|
|
// Error continues the flow execution to the specified next state with an error.
|
|
Error(flowErr FlowError) error
|
|
// Revert reverts the flow back to the previous state.
|
|
Revert() error
|
|
|
|
actionExecutionContext
|
|
}
|
|
|
|
type actionSuspender interface {
|
|
// SuspendAction suspends the current action's execution.
|
|
SuspendAction()
|
|
}
|
|
|
|
type Context interface {
|
|
context
|
|
}
|
|
|
|
// InitializationContext is a shorthand for actionInitializationContext within the flow initialization method.
|
|
type InitializationContext interface {
|
|
context
|
|
actionInitializationContext
|
|
}
|
|
|
|
// ExecutionContext is a shorthand for actionExecutionContinuationContext within flow execution method.
|
|
type ExecutionContext interface {
|
|
context
|
|
actionExecutionContinuationContext
|
|
}
|
|
|
|
type HookExecutionContext interface {
|
|
context
|
|
actionExecutionContext
|
|
|
|
GetFlowError() FlowError
|
|
AddLink(...Link)
|
|
ScheduleStates(...StateName)
|
|
}
|
|
|
|
type BeforeEachActionExecutionContext interface {
|
|
actionExecutionContinuationContext
|
|
}
|
|
|
|
// createAndInitializeFlow initializes the flow and returns a flow Response.
|
|
func createAndInitializeFlow(db FlowDB, flow defaultFlow) (FlowResult, error) {
|
|
// Wrap the provided FlowDB with additional functionality.
|
|
dbw := wrapDB(db)
|
|
// Calculate the expiration time for the flow.
|
|
expiresAt := time.Now().Add(flow.ttl).UTC()
|
|
|
|
// Initialize the stash and add initial next states.
|
|
s, err := newStash(flow.initialStateNames...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to initialize a new stash: %w", err)
|
|
}
|
|
|
|
s.useCompression(flow.useCompression)
|
|
|
|
p := newPayload()
|
|
|
|
csrfToken, err := generateRandomString(32)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate csrf token: %w", err)
|
|
}
|
|
|
|
// Create a new flow model with the provided parameters.
|
|
flowCreation := flowCreationParam{
|
|
data: s.String(),
|
|
csrfToken: csrfToken,
|
|
expiresAt: expiresAt,
|
|
}
|
|
flowModel, err := dbw.createFlowWithParam(flowCreation)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create flow: %w", err)
|
|
}
|
|
|
|
// Create a defaultFlowContext instance.
|
|
fc := &defaultFlowContext{
|
|
flow: flow,
|
|
dbw: dbw,
|
|
flowModel: flowModel,
|
|
stash: s,
|
|
payload: p,
|
|
}
|
|
|
|
er := executionResult{nextStateName: s.getStateName()}
|
|
|
|
aec := defaultActionExecutionContext{
|
|
actionName: "",
|
|
inputSchema: nil,
|
|
executionResult: &er,
|
|
defaultFlowContext: fc,
|
|
}
|
|
|
|
err = aec.executeBeforeStateHooks(aec.stash.getStateName())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to execute before hook actions: %w", err)
|
|
}
|
|
|
|
return er.generateResponse(fc), nil
|
|
}
|
|
|
|
// executeFlowAction processes the flow and returns a Response.
|
|
func executeFlowAction(db FlowDB, flow defaultFlow) (FlowResult, error) {
|
|
actionName := flow.queryParam.getActionName()
|
|
|
|
// Retrieve the flow model from the database using the flow ID.
|
|
flowModel, err := db.GetFlow(flow.queryParam.getFlowID())
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return newFlowResultFromError(flow.errorStateName, ErrorOperationNotPermitted.Wrap(err), flow.debug), nil
|
|
}
|
|
return nil, fmt.Errorf("failed to get flow: %w", err)
|
|
}
|
|
|
|
// Check if the flow has expired.
|
|
if time.Now().After(flowModel.ExpiresAt) {
|
|
return newFlowResultFromError(flow.errorStateName, ErrorFlowExpired, flow.debug), nil
|
|
}
|
|
|
|
// Parse stash data from the flow model.
|
|
s, err := newStashFromString(flowModel.Data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse stash from flow: %w", err)
|
|
}
|
|
|
|
s.useCompression(flow.useCompression)
|
|
|
|
// Initialize JSONManagers for payload and flash data.
|
|
p := newPayload()
|
|
|
|
// Parse raw input data into JSONManager.
|
|
inputJSON, err := newActionInputFromInputData(flow.inputData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse input data: %w", err)
|
|
}
|
|
csrfTokenToValidate := flow.inputData.CSRFToken
|
|
|
|
if len(flowModel.CSRFToken) <= 0 || flowModel.CSRFToken != csrfTokenToValidate {
|
|
err = errors.New("csrf token mismatch")
|
|
return newFlowResultFromError(flow.errorStateName, ErrorOperationNotPermitted.Wrap(err), flow.debug), nil
|
|
}
|
|
|
|
// Create a defaultFlowContext instance.
|
|
fc := &defaultFlowContext{
|
|
flow: flow,
|
|
dbw: wrapDB(db),
|
|
flowModel: flowModel,
|
|
stash: s,
|
|
payload: p,
|
|
}
|
|
|
|
state, err := flow.getState(s.getStateName())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get the action associated with the actionParam name.
|
|
ad, err := state.getActionDetail(actionName)
|
|
if err != nil {
|
|
return newFlowResultFromError(flow.errorStateName, ErrorOperationNotPermitted.Wrap(err), flow.debug), nil
|
|
}
|
|
|
|
// Initialize the inputSchema and action context for action execution.
|
|
inputSchema := newSchemaWithInputData(inputJSON)
|
|
aic := &defaultActionInitializationContext{
|
|
inputSchema: inputSchema.forInitializationContext(),
|
|
defaultFlowContext: fc,
|
|
}
|
|
|
|
// Create a actionExecutionContext instance for action execution.
|
|
aec := &defaultActionExecutionContext{
|
|
actionName: actionName,
|
|
inputSchema: inputSchema,
|
|
defaultFlowContext: fc,
|
|
}
|
|
|
|
err = aec.executeBeforeEachActionHooks()
|
|
if err != nil {
|
|
return newFlowResultFromError(flow.errorStateName, ErrorOperationNotPermitted, flow.debug), nil
|
|
}
|
|
|
|
ad.getAction().Initialize(aic)
|
|
|
|
// Check if the action is suspended.
|
|
if aic.isSuspended {
|
|
return newFlowResultFromError(flow.errorStateName, ErrorOperationNotPermitted, flow.debug), nil
|
|
}
|
|
|
|
// Execute the action and handle any errors.
|
|
err = ad.getAction().Execute(aec)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the action failed to handle the request: %w", err)
|
|
}
|
|
|
|
// Ensure that the action has set a result object.
|
|
if aec.executionResult == nil {
|
|
er := executionResult{nextStateName: s.getStateName()}
|
|
aec.executionResult = &er
|
|
}
|
|
|
|
// Generate a response based on the execution result.
|
|
return aec.executionResult.generateResponse(fc), nil
|
|
}
|