Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: trusted devices and 'remember me' #1982

Merged
merged 5 commits into from
Nov 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -176,6 +177,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"`
// `device_trust_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(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
}
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
Loading