Skip to content

Commit

Permalink
feat: trusted devices and 'remember me'
Browse files Browse the repository at this point in the history
  • Loading branch information
bjoern-m committed Nov 25, 2024
1 parent b643256 commit 536927f
Show file tree
Hide file tree
Showing 41 changed files with 681 additions and 17 deletions.
11 changes: 11 additions & 0 deletions backend/config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ log:
mfa:
acquire_on_login: false
acquire_on_registration: true
device_trust_cookie_name: hanko-device-token
device_trust_duration: 720h
device_trust_policy: prompt
enabled: true
optional: true
security_keys:
Expand Down Expand Up @@ -89,6 +92,14 @@ service:
session:
lifespan: 12h
enable_auth_token_header: false
server_side:
enabled: false
limit: 100
cookie:
http_only: true
retention: persistent
same_site: strict
secure: true
third_party:
providers:
apple:
Expand Down
10 changes: 7 additions & 3 deletions backend/config/config_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,10 @@ func DefaultConfig() *Config {
Session: Session{
Lifespan: "12h",
Cookie: Cookie{
HttpOnly: true,
SameSite: "strict",
Secure: true,
HttpOnly: true,
Retention: "persistent",
SameSite: "strict",
Secure: true,
},
ServerSide: ServerSide{
Enabled: false,
Expand Down Expand Up @@ -168,6 +169,9 @@ func DefaultConfig() *Config {
MFA: MFA{
AcquireOnLogin: false,
AcquireOnRegistration: true,
DeviceTrustCookieName: "hanko-device-token",
DeviceTrustDuration: 30 * 24 * time.Hour, // 30 days
DeviceTrustPolicy: "prompt",
Enabled: true,
Optional: true,
SecurityKeys: SecurityKeys{
Expand Down
22 changes: 22 additions & 0 deletions backend/config/config_mfa.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
package config

import (
"github.com/invopop/jsonschema"
"time"
)

type SecurityKeys struct {
// `attestation_preference` is used to specify the preference regarding attestation conveyance during
// credential generation.
Expand Down Expand Up @@ -28,6 +33,14 @@ type MFA struct {
AcquireOnLogin bool `yaml:"acquire_on_login" json:"acquire_on_login" koanf:"acquire_on_login" jsonschema:"default=false"`
// `acquire_on_registration` configures if users are prompted creating an MFA credential on registration.
AcquireOnRegistration bool `yaml:"acquire_on_registration" json:"acquire_on_registration" koanf:"acquire_on_registration" jsonschema:"default=true"`
// `device_trust_cookie_name` is the name of the cookie used to store the token of a trusted device.
DeviceTrustCookieName string `yaml:"device_trust_cookie_name" json:"device_trust_cookie_name,omitempty" koanf:"device_trust_cookie_name" jsonschema:"default=hanko_device_token"`
// `device_trust_duration` configures the duration a device remains trusted after authentication; once expired, the
// user must reauthenticate with MFA.
DeviceTrustDuration time.Duration `yaml:"device_trust_duration" json:"device_trust_duration" koanf:"device_trust_duration" jsonschema:"default=720h,type=string"`
// `trust_device_policy` determines the conditions under which a device or browser is considered trusted, allowing
// MFA to be skipped for subsequent logins.
DeviceTrustPolicy string `yaml:"device_trust_policy" json:"device_trust_policy,omitempty" koanf:"device_trust_policy" split_words:"true" jsonschema:"default=prompt,enum=always,enum=prompt,enum=never"`
// `enabled` determines whether multi-factor-authentication is enabled.
Enabled bool `yaml:"enabled" json:"enabled" koanf:"enabled" jsonschema:"default=true"`
// `optional` determines whether users must create an MFA credential when prompted. The MFA credential cannot be
Expand All @@ -38,3 +51,12 @@ type MFA struct {
// `totp` configures the TOTP (Time-Based One-Time-Password) method for multi-factor-authentication.
TOTP TOTP `yaml:"totp" json:"totp,omitempty" koanf:"totp" jsonschema:"title=totp"`
}

func (MFA) JSONSchemaExtend(schema *jsonschema.Schema) {
deviceTrustPolicy, _ := schema.Properties.Get("device_trust_policy")
deviceTrustPolicy.Extras = map[string]any{"meta:enum": map[string]string{
"always": "Devices are trusted without user consent until the trust expires, so MFA is skipped during subsequent logins.",
"prompt": "The user can choose to trust the current device to skip MFA for subsequent logins.",
"never": "Devices are considered untrusted, so MFA is required for each login.",
}}
}
12 changes: 12 additions & 0 deletions backend/config/config_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"errors"
"github.com/invopop/jsonschema"
"time"
)

Expand Down Expand Up @@ -44,6 +45,8 @@ type Cookie struct {
HttpOnly bool `yaml:"http_only" json:"http_only,omitempty" koanf:"http_only" split_words:"true" jsonschema:"default=true"`
// `name` is the name of the cookie.
Name string `yaml:"name" json:"name,omitempty" koanf:"name" jsonschema:"default=hanko"`
// `retention` determines the retention behavior of authentication cookies.
Retention string `yaml:"retention" json:"retention,omitempty" koanf:"retention" split_words:"true" jsonschema:"default=persistent,enum=session,enum=persistent,enum=prompt"`
// `same_site` controls whether a cookie is sent with cross-site requests.
// See [here](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value) for
// more details.
Expand All @@ -56,6 +59,15 @@ type Cookie struct {
Secure bool `yaml:"secure" json:"secure,omitempty" koanf:"secure" jsonschema:"default=true"`
}

func (Cookie) JSONSchemaExtend(schema *jsonschema.Schema) {
retention, _ := schema.Properties.Get("retention")
retention.Extras = map[string]any{"meta:enum": map[string]string{
"session": "Issues a temporary cookie that lasts for the duration of the browser session.",
"persistent": "Issues a cookie that remains stored on the user's device until it reaches its expiration date.",
"prompt": "Allows the user to choose whether to stay signed in. If the user selects 'Stay signed in', a persistent cookie is issued; a session cookie otherwise.",
}}
}

func (c *Cookie) GetName() string {
if c.Name != "" {
return c.Name
Expand Down
44 changes: 44 additions & 0 deletions backend/flow_api/flow/credential_usage/action_remember_me.go
Original file line number Diff line number Diff line change
@@ -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 changes: 31 additions & 0 deletions backend/flow_api/flow/device_trust/action_trust_device.go
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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(62)
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 storer 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
}
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 12 additions & 0 deletions backend/flow_api/flow/flows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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{},
Expand Down Expand Up @@ -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).
Expand All @@ -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,
Expand All @@ -126,6 +136,7 @@ func NewLoginFlow(debug bool) flowpilot.Flow {
CapabilitiesSubFlow,
CredentialUsageSubFlow,
CredentialOnboardingSubFlow,
DeviceTrustSubFlow,
UserDetailsSubFlow,
MFACreationSubFlow,
MFAUsageSubFlow).
Expand All @@ -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{}).
Expand Down
Loading

0 comments on commit 536927f

Please sign in to comment.