mirror of
https://github.com/teamhanko/hanko.git
synced 2025-10-29 15:49:41 +08:00
feat: trusted devices and 'remember me' (#1982)
This commit is contained in:
44
backend/flow_api/flow/credential_usage/action_remember_me.go
Normal file
44
backend/flow_api/flow/credential_usage/action_remember_me.go
Normal file
@ -0,0 +1,44 @@
|
||||
package credential_usage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/teamhanko/hanko/backend/flow_api/flow/shared"
|
||||
"github.com/teamhanko/hanko/backend/flowpilot"
|
||||
)
|
||||
|
||||
type RememberMe struct {
|
||||
shared.Action
|
||||
}
|
||||
|
||||
func (a RememberMe) GetName() flowpilot.ActionName {
|
||||
return shared.ActionRememberMe
|
||||
}
|
||||
|
||||
func (a RememberMe) GetDescription() string {
|
||||
return "Enables the user to stay signed in."
|
||||
}
|
||||
|
||||
func (a RememberMe) Initialize(c flowpilot.InitializationContext) {
|
||||
deps := a.GetDeps(c)
|
||||
|
||||
c.AddInputs(flowpilot.BooleanInput("remember_me").Required(true))
|
||||
|
||||
if deps.Cfg.Session.Cookie.Retention != "prompt" {
|
||||
c.SuspendAction()
|
||||
}
|
||||
}
|
||||
|
||||
func (a RememberMe) Execute(c flowpilot.ExecutionContext) error {
|
||||
if valid := c.ValidateInputData(); !valid {
|
||||
return c.Error(flowpilot.ErrorFormDataInvalid)
|
||||
}
|
||||
|
||||
rememberMeSelected := c.Input().Get("remember_me").Bool()
|
||||
|
||||
if err := c.Stash().Set(shared.StashPathRememberMeSelected, rememberMeSelected); err != nil {
|
||||
return fmt.Errorf("failed to set remember_me_selected to stash: %w", err)
|
||||
}
|
||||
|
||||
return c.Continue(c.GetCurrentState())
|
||||
}
|
||||
31
backend/flow_api/flow/device_trust/action_trust_device.go
Normal file
31
backend/flow_api/flow/device_trust/action_trust_device.go
Normal file
@ -0,0 +1,31 @@
|
||||
package device_trust
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/teamhanko/hanko/backend/flow_api/flow/shared"
|
||||
"github.com/teamhanko/hanko/backend/flowpilot"
|
||||
)
|
||||
|
||||
type TrustDevice struct {
|
||||
shared.Action
|
||||
}
|
||||
|
||||
func (a TrustDevice) GetName() flowpilot.ActionName {
|
||||
return shared.ActionTrustDevice
|
||||
}
|
||||
|
||||
func (a TrustDevice) GetDescription() string {
|
||||
return "Trust this device, to skip MFA on subsequent logins."
|
||||
}
|
||||
|
||||
func (a TrustDevice) Initialize(c flowpilot.InitializationContext) {}
|
||||
|
||||
func (a TrustDevice) Execute(c flowpilot.ExecutionContext) error {
|
||||
if err := c.Stash().Set(shared.StashPathDeviceTrustGranted, true); err != nil {
|
||||
return fmt.Errorf("failed to set device_trust_granted to the stash: %w", err)
|
||||
}
|
||||
|
||||
c.PreventRevert()
|
||||
|
||||
return c.Continue()
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
package device_trust
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/teamhanko/hanko/backend/flow_api/flow/shared"
|
||||
"github.com/teamhanko/hanko/backend/flow_api/services"
|
||||
"github.com/teamhanko/hanko/backend/flowpilot"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type IssueTrustDeviceCookie struct {
|
||||
shared.Action
|
||||
}
|
||||
|
||||
func (h IssueTrustDeviceCookie) Execute(c flowpilot.HookExecutionContext) error {
|
||||
var err error
|
||||
|
||||
deps := h.GetDeps(c)
|
||||
|
||||
if deps.Cfg.MFA.DeviceTrustPolicy == "never" ||
|
||||
(deps.Cfg.MFA.DeviceTrustPolicy == "prompt" && !c.Stash().Get(shared.StashPathDeviceTrustGranted).Bool()) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !c.Stash().Get(shared.StashPathUserID).Exists() {
|
||||
return fmt.Errorf("user id does not exist in the stash")
|
||||
}
|
||||
|
||||
userID, err := uuid.FromString(c.Stash().Get(shared.StashPathUserID).String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse stashed user_id into a uuid: %w", err)
|
||||
}
|
||||
|
||||
deviceTrustService := services.DeviceTrustService{
|
||||
Persister: deps.Persister.GetTrustedDevicePersisterWithConnection(deps.Tx),
|
||||
Cfg: deps.Cfg,
|
||||
HttpContext: deps.HttpContext,
|
||||
}
|
||||
|
||||
deviceToken, err := deviceTrustService.GenerateRandomToken(64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate trusted device token: %w", err)
|
||||
}
|
||||
|
||||
name := deps.Cfg.MFA.DeviceTrustCookieName
|
||||
maxAge := int(deps.Cfg.MFA.DeviceTrustDuration.Seconds())
|
||||
|
||||
if maxAge > 0 {
|
||||
err = deviceTrustService.CreateTrustedDevice(userID, deviceToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store trusted device: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
cookie := new(http.Cookie)
|
||||
cookie.Name = name
|
||||
cookie.Value = deviceToken
|
||||
cookie.Path = "/"
|
||||
cookie.HttpOnly = true
|
||||
cookie.Secure = true
|
||||
cookie.MaxAge = maxAge
|
||||
|
||||
deps.HttpContext.SetCookie(cookie)
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
package device_trust
|
||||
|
||||
import (
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/teamhanko/hanko/backend/flow_api/flow/shared"
|
||||
"github.com/teamhanko/hanko/backend/flow_api/services"
|
||||
"github.com/teamhanko/hanko/backend/flowpilot"
|
||||
)
|
||||
|
||||
type ScheduleTrustDeviceState struct {
|
||||
shared.Action
|
||||
}
|
||||
|
||||
func (h ScheduleTrustDeviceState) Execute(c flowpilot.HookExecutionContext) error {
|
||||
deps := h.GetDeps(c)
|
||||
|
||||
if !deps.Cfg.MFA.Enabled || deps.Cfg.MFA.DeviceTrustPolicy != "prompt" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if c.IsFlow(shared.FlowLogin) && c.Stash().Get(shared.StashPathLoginMethod).String() == "passkey" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !c.Stash().Get(shared.StashPathUserHasSecurityKey).Bool() &&
|
||||
!c.Stash().Get(shared.StashPathUserHasOTPSecret).Bool() {
|
||||
return nil
|
||||
}
|
||||
|
||||
deviceTrustService := services.DeviceTrustService{
|
||||
Persister: deps.Persister.GetTrustedDevicePersisterWithConnection(deps.Tx),
|
||||
Cfg: deps.Cfg,
|
||||
HttpContext: deps.HttpContext,
|
||||
}
|
||||
|
||||
userID := uuid.FromStringOrNil(c.Stash().Get(shared.StashPathUserID).String())
|
||||
|
||||
if !deviceTrustService.CheckDeviceTrust(userID) {
|
||||
c.ScheduleStates(shared.StateDeviceTrust)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"github.com/teamhanko/hanko/backend/flow_api/flow/capabilities"
|
||||
"github.com/teamhanko/hanko/backend/flow_api/flow/credential_onboarding"
|
||||
"github.com/teamhanko/hanko/backend/flow_api/flow/credential_usage"
|
||||
"github.com/teamhanko/hanko/backend/flow_api/flow/device_trust"
|
||||
"github.com/teamhanko/hanko/backend/flow_api/flow/login"
|
||||
"github.com/teamhanko/hanko/backend/flow_api/flow/mfa_creation"
|
||||
"github.com/teamhanko/hanko/backend/flow_api/flow/mfa_usage"
|
||||
@ -24,6 +25,7 @@ var CredentialUsageSubFlow = flowpilot.NewSubFlow(shared.FlowCredentialUsage).
|
||||
credential_usage.ContinueWithLoginIdentifier{},
|
||||
credential_usage.WebauthnGenerateRequestOptions{},
|
||||
credential_usage.WebauthnVerifyAssertionResponse{},
|
||||
credential_usage.RememberMe{},
|
||||
shared.ThirdPartyOAuth{}).
|
||||
State(shared.StateLoginPasskey,
|
||||
credential_usage.WebauthnVerifyAssertionResponse{},
|
||||
@ -103,6 +105,13 @@ var MFAUsageSubFlow = flowpilot.NewSubFlow(shared.FlowMFAUsage).
|
||||
mfa_usage.ContinueToLoginSecurityKey{}).
|
||||
MustBuild()
|
||||
|
||||
var DeviceTrustSubFlow = flowpilot.NewSubFlow(shared.FlowDeviceTrust).
|
||||
State(shared.StateDeviceTrust,
|
||||
device_trust.TrustDevice{},
|
||||
shared.Skip{},
|
||||
shared.Back{}).
|
||||
MustBuild()
|
||||
|
||||
func NewLoginFlow(debug bool) flowpilot.Flow {
|
||||
return flowpilot.NewFlow(shared.FlowLogin).
|
||||
State(shared.StateSuccess).
|
||||
@ -111,6 +120,7 @@ func NewLoginFlow(debug bool) flowpilot.Flow {
|
||||
BeforeState(shared.StateLoginInit,
|
||||
login.WebauthnGenerateRequestOptionsForConditionalUi{}).
|
||||
BeforeState(shared.StateSuccess,
|
||||
device_trust.IssueTrustDeviceCookie{},
|
||||
shared.IssueSession{},
|
||||
shared.GetUserData{}).
|
||||
AfterState(shared.StateOnboardingVerifyPasskeyAttestation,
|
||||
@ -126,6 +136,7 @@ func NewLoginFlow(debug bool) flowpilot.Flow {
|
||||
CapabilitiesSubFlow,
|
||||
CredentialUsageSubFlow,
|
||||
CredentialOnboardingSubFlow,
|
||||
DeviceTrustSubFlow,
|
||||
UserDetailsSubFlow,
|
||||
MFACreationSubFlow,
|
||||
MFAUsageSubFlow).
|
||||
@ -138,6 +149,7 @@ func NewRegistrationFlow(debug bool) flowpilot.Flow {
|
||||
return flowpilot.NewFlow(shared.FlowRegistration).
|
||||
State(shared.StateRegistrationInit,
|
||||
registration.RegisterLoginIdentifier{},
|
||||
credential_usage.RememberMe{},
|
||||
shared.ThirdPartyOAuth{}).
|
||||
State(shared.StateThirdParty,
|
||||
shared.ExchangeToken{}).
|
||||
|
||||
@ -2,7 +2,10 @@ package login
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/teamhanko/hanko/backend/flow_api/flow/device_trust"
|
||||
"github.com/teamhanko/hanko/backend/flow_api/flow/shared"
|
||||
"github.com/teamhanko/hanko/backend/flow_api/services"
|
||||
"github.com/teamhanko/hanko/backend/flowpilot"
|
||||
)
|
||||
|
||||
@ -31,8 +34,13 @@ func (h ScheduleOnboardingStates) Execute(c flowpilot.HookExecutionContext) erro
|
||||
|
||||
c.ScheduleStates(userDetailOnboardingStates...)
|
||||
c.ScheduleStates(credentialOnboardingStates...)
|
||||
c.ScheduleStates(shared.StateSuccess)
|
||||
|
||||
err := c.ExecuteHook(device_trust.ScheduleTrustDeviceState{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.ScheduleStates(shared.StateSuccess)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -49,6 +57,19 @@ func (h ScheduleOnboardingStates) determineMFAUsageStates(c flowpilot.HookExecut
|
||||
return result
|
||||
}
|
||||
|
||||
deviceTrustService := services.DeviceTrustService{
|
||||
Persister: deps.Persister.GetTrustedDevicePersisterWithConnection(deps.Tx),
|
||||
Cfg: deps.Cfg,
|
||||
HttpContext: deps.HttpContext,
|
||||
}
|
||||
|
||||
userID := uuid.FromStringOrNil(c.Stash().Get(shared.StashPathUserID).String())
|
||||
|
||||
if deviceTrustService.CheckDeviceTrust(userID) {
|
||||
// The device is trusted, so MFA can be skipped.
|
||||
return result
|
||||
}
|
||||
|
||||
userHasSecurityKey := c.Stash().Get(shared.StashPathUserHasSecurityKey).Bool()
|
||||
userHasOTPSecret := c.Stash().Get(shared.StashPathUserHasOTPSecret).Bool()
|
||||
attachmentSupported := c.Stash().Get(shared.StashPathSecurityKeyAttachmentSupported).Bool()
|
||||
|
||||
@ -42,6 +42,8 @@ func (a OTPCodeVerify) Execute(c flowpilot.ExecutionContext) error {
|
||||
return c.Error(shared.ErrorPasscodeInvalid)
|
||||
}
|
||||
|
||||
_ = c.Stash().Set(shared.StashPathUserHasOTPSecret, true)
|
||||
|
||||
if c.GetFlowName() != shared.FlowRegistration {
|
||||
var userID uuid.UUID
|
||||
if c.GetFlowName() == shared.FlowLogin {
|
||||
|
||||
@ -160,19 +160,15 @@ func (a RegisterLoginIdentifier) Execute(c flowpilot.ExecutionContext) error {
|
||||
}
|
||||
}
|
||||
|
||||
states := a.generateRegistrationStates(c)
|
||||
|
||||
if len(states) == 0 {
|
||||
err = c.ExecuteHook(shared.ScheduleMFACreationStates{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
states, err := a.generateRegistrationStates(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Continue(append(states, shared.StateSuccess)...)
|
||||
}
|
||||
|
||||
func (a RegisterLoginIdentifier) generateRegistrationStates(c flowpilot.ExecutionContext) []flowpilot.StateName {
|
||||
func (a RegisterLoginIdentifier) generateRegistrationStates(c flowpilot.ExecutionContext) ([]flowpilot.StateName, error) {
|
||||
deps := a.GetDeps(c)
|
||||
|
||||
result := make([]flowpilot.StateName, 0)
|
||||
@ -220,5 +216,12 @@ func (a RegisterLoginIdentifier) generateRegistrationStates(c flowpilot.Executio
|
||||
result = append(result, shared.StatePasswordCreation)
|
||||
}
|
||||
|
||||
return result
|
||||
if len(result) == 0 {
|
||||
err := c.ExecuteHook(shared.ScheduleMFACreationStates{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@ -36,6 +36,11 @@ func (h CreateUser) Execute(c flowpilot.HookExecutionContext) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse stashed user_id into a uuid: %w", err)
|
||||
}
|
||||
} else {
|
||||
err = c.Stash().Set(shared.StashPathUserID, userId.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set user_id to the stash: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = h.createUser(
|
||||
|
||||
@ -32,11 +32,13 @@ const (
|
||||
ActionRegisterClientCapabilities flowpilot.ActionName = "register_client_capabilities"
|
||||
ActionRegisterLoginIdentifier flowpilot.ActionName = "register_login_identifier"
|
||||
ActionRegisterPassword flowpilot.ActionName = "register_password"
|
||||
ActionRememberMe flowpilot.ActionName = "remember_me"
|
||||
ActionResendPasscode flowpilot.ActionName = "resend_passcode"
|
||||
ActionSecurityKeyCreate flowpilot.ActionName = "security_key_create"
|
||||
ActionSecurityKeyDelete flowpilot.ActionName = "security_key_delete"
|
||||
ActionSkip flowpilot.ActionName = "skip"
|
||||
ActionThirdPartyOAuth flowpilot.ActionName = "thirdparty_oauth"
|
||||
ActionTrustDevice flowpilot.ActionName = "trust_device"
|
||||
ActionUsernameCreate flowpilot.ActionName = "username_create"
|
||||
ActionUsernameDelete flowpilot.ActionName = "username_delete"
|
||||
ActionUsernameUpdate flowpilot.ActionName = "username_update"
|
||||
|
||||
@ -6,6 +6,7 @@ const (
|
||||
FlowCapabilities flowpilot.FlowName = "capabilities"
|
||||
FlowCredentialOnboarding flowpilot.FlowName = "credential_onboarding"
|
||||
FlowCredentialUsage flowpilot.FlowName = "credential_usage"
|
||||
FlowDeviceTrust flowpilot.FlowName = "device_trust"
|
||||
FlowLogin flowpilot.FlowName = "login"
|
||||
FlowMFACreation flowpilot.FlowName = "mfa_creation"
|
||||
FlowProfile flowpilot.FlowName = "profile"
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package shared
|
||||
|
||||
const (
|
||||
StashPathDeviceTrustGranted = "device_trust_granted"
|
||||
StashPathEmail = "email"
|
||||
StashPathEmailVerified = "email_verified"
|
||||
StashPathLoginMethod = "login_method"
|
||||
@ -15,6 +16,7 @@ const (
|
||||
StashPathPasscodeID = "sticky.passcode_id"
|
||||
StashPathPasscodeTemplate = "passcode_template"
|
||||
StashPathPasswordRecoveryPending = "pw_recovery_pending"
|
||||
StashPathRememberMeSelected = "remember_me_selected"
|
||||
StashPathSecurityKeyAttachmentSupported = "security_key_attachment_supported"
|
||||
StashPathSkipUserCreation = "skip_user_creation"
|
||||
StashPathThirdPartyProvider = "third_party_provider"
|
||||
|
||||
@ -4,6 +4,7 @@ import "github.com/teamhanko/hanko/backend/flowpilot"
|
||||
|
||||
const (
|
||||
StateCredentialOnboardingChooser flowpilot.StateName = "credential_onboarding_chooser"
|
||||
StateDeviceTrust flowpilot.StateName = "device_trust"
|
||||
StateError flowpilot.StateName = "error"
|
||||
StateLoginInit flowpilot.StateName = "login_init"
|
||||
StateLoginMethodChooser flowpilot.StateName = "login_method_chooser"
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"github.com/teamhanko/hanko/backend/dto"
|
||||
"github.com/teamhanko/hanko/backend/flowpilot"
|
||||
"github.com/teamhanko/hanko/backend/persistence/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
type IssueSession struct {
|
||||
@ -80,12 +81,27 @@ func (h IssueSession) Execute(c flowpilot.HookExecutionContext) error {
|
||||
}
|
||||
}
|
||||
|
||||
rememberMeSelected := c.Stash().Get(StashPathRememberMeSelected).Bool()
|
||||
cookie, err := deps.SessionManager.GenerateCookie(signedSessionToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate auth cookie, %w", err)
|
||||
}
|
||||
|
||||
deps.HttpContext.Response().Header().Set("X-Session-Lifetime", fmt.Sprintf("%d", cookie.MaxAge))
|
||||
lifespan, err := time.ParseDuration(deps.Cfg.Session.Lifespan)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse session lifespan: %w", err)
|
||||
}
|
||||
|
||||
sessionRetention := "persistent"
|
||||
if deps.Cfg.Session.Cookie.Retention == "session" ||
|
||||
(deps.Cfg.Session.Cookie.Retention == "prompt" && !rememberMeSelected) {
|
||||
// Issue a session cookie.
|
||||
cookie.MaxAge = 0
|
||||
sessionRetention = "session"
|
||||
}
|
||||
|
||||
deps.HttpContext.Response().Header().Set("X-Session-Lifetime", fmt.Sprintf("%d", int(lifespan.Seconds())))
|
||||
deps.HttpContext.Response().Header().Set("X-Session-Retention", fmt.Sprintf("%s", sessionRetention))
|
||||
|
||||
if deps.Cfg.Session.EnableAuthTokenHeader {
|
||||
deps.HttpContext.Response().Header().Set("X-Auth-Token", signedSessionToken)
|
||||
|
||||
Reference in New Issue
Block a user