feat: trusted devices and 'remember me' (#1982)

This commit is contained in:
bjoern-m
2024-11-29 11:06:47 +01:00
committed by GitHub
parent 298fa19423
commit f32f48e85b
41 changed files with 686 additions and 17 deletions

View 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())
}

View 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()
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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{}).

View File

@ -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()

View File

@ -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 {

View File

@ -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
}

View File

@ -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(

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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)