From 536927f6a6cb3605b7da91ed98c5a06d19526279 Mon Sep 17 00:00:00 2001 From: bjoern-m Date: Thu, 7 Nov 2024 19:14:16 +0100 Subject: [PATCH] feat: trusted devices and 'remember me' --- backend/config/config.yaml | 11 +++ backend/config/config_default.go | 10 ++- backend/config/config_mfa.go | 22 +++++ backend/config/config_session.go | 12 +++ .../credential_usage/action_remember_me.go | 44 ++++++++++ .../flow/device_trust/action_trust_device.go | 31 +++++++ .../hook_issue_trust_device_cookie.go | 67 +++++++++++++++ .../hook_schedule_trust_device_state.go | 43 ++++++++++ backend/flow_api/flow/flows.go | 12 +++ .../login/hook_schedule_onboarding_states.go | 23 ++++- .../mfa_creation/action_otp_code_verify.go | 2 + .../action_register_login_identifier.go | 21 +++-- .../flow/registration/hook_create_user.go | 5 ++ .../flow/shared/const_action_names.go | 2 + .../flow_api/flow/shared/const_flow_names.go | 1 + .../flow_api/flow/shared/const_stash_paths.go | 2 + .../flow_api/flow/shared/const_state_names.go | 1 + .../flow/shared/hook_issue_session.go | 16 +++- backend/flow_api/services/device_trust.go | 71 +++++++++++++++ backend/handler/public_router.go | 1 + ...112181011_create_trusted_devices.down.fizz | 1 + ...41112181011_create_trusted_devices.up.fizz | 8 ++ backend/persistence/models/trusted_device.go | 29 +++++++ backend/persistence/persister.go | 10 +++ .../persistence/trusted_device_persister.go | 47 ++++++++++ .../elements/src/contexts/AppProvider.tsx | 7 +- frontend/elements/src/i18n/bn.ts | 5 ++ frontend/elements/src/i18n/de.ts | 5 ++ frontend/elements/src/i18n/en.ts | 5 ++ frontend/elements/src/i18n/fr.ts | 5 ++ frontend/elements/src/i18n/it.ts | 5 ++ frontend/elements/src/i18n/pt-BR.ts | 5 ++ frontend/elements/src/i18n/translations.ts | 4 + frontend/elements/src/i18n/zh.ts | 5 ++ .../elements/src/pages/DeviceTrustPage.tsx | 86 +++++++++++++++++++ frontend/elements/src/pages/LoginInitPage.tsx | 23 +++++ .../src/pages/RegistrationInitPage.tsx | 23 +++++ .../frontend-sdk/src/lib/client/HttpClient.ts | 9 +- .../src/lib/flow-api/types/action.ts | 9 ++ .../src/lib/flow-api/types/input.ts | 5 ++ .../src/lib/flow-api/types/state-handling.ts | 5 +- 41 files changed, 681 insertions(+), 17 deletions(-) create mode 100644 backend/flow_api/flow/credential_usage/action_remember_me.go create mode 100644 backend/flow_api/flow/device_trust/action_trust_device.go create mode 100644 backend/flow_api/flow/device_trust/hook_issue_trust_device_cookie.go create mode 100644 backend/flow_api/flow/device_trust/hook_schedule_trust_device_state.go create mode 100644 backend/flow_api/services/device_trust.go create mode 100644 backend/persistence/migrations/20241112181011_create_trusted_devices.down.fizz create mode 100644 backend/persistence/migrations/20241112181011_create_trusted_devices.up.fizz create mode 100644 backend/persistence/models/trusted_device.go create mode 100644 backend/persistence/trusted_device_persister.go create mode 100644 frontend/elements/src/pages/DeviceTrustPage.tsx diff --git a/backend/config/config.yaml b/backend/config/config.yaml index 9596cf58e..9c824a493 100644 --- a/backend/config/config.yaml +++ b/backend/config/config.yaml @@ -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: @@ -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: diff --git a/backend/config/config_default.go b/backend/config/config_default.go index fde7e0f3b..971ce8154 100644 --- a/backend/config/config_default.go +++ b/backend/config/config_default.go @@ -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, @@ -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{ diff --git a/backend/config/config_mfa.go b/backend/config/config_mfa.go index 0d75fd038..60cbf9ec1 100644 --- a/backend/config/config_mfa.go +++ b/backend/config/config_mfa.go @@ -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. @@ -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 @@ -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.", + }} +} diff --git a/backend/config/config_session.go b/backend/config/config_session.go index 1539cee72..cb53c9e0f 100644 --- a/backend/config/config_session.go +++ b/backend/config/config_session.go @@ -2,6 +2,7 @@ package config import ( "errors" + "github.com/invopop/jsonschema" "time" ) @@ -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. @@ -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 diff --git a/backend/flow_api/flow/credential_usage/action_remember_me.go b/backend/flow_api/flow/credential_usage/action_remember_me.go new file mode 100644 index 000000000..cd1c29fd2 --- /dev/null +++ b/backend/flow_api/flow/credential_usage/action_remember_me.go @@ -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()) +} diff --git a/backend/flow_api/flow/device_trust/action_trust_device.go b/backend/flow_api/flow/device_trust/action_trust_device.go new file mode 100644 index 000000000..406e4329b --- /dev/null +++ b/backend/flow_api/flow/device_trust/action_trust_device.go @@ -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() +} diff --git a/backend/flow_api/flow/device_trust/hook_issue_trust_device_cookie.go b/backend/flow_api/flow/device_trust/hook_issue_trust_device_cookie.go new file mode 100644 index 000000000..541768ee2 --- /dev/null +++ b/backend/flow_api/flow/device_trust/hook_issue_trust_device_cookie.go @@ -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 +} diff --git a/backend/flow_api/flow/device_trust/hook_schedule_trust_device_state.go b/backend/flow_api/flow/device_trust/hook_schedule_trust_device_state.go new file mode 100644 index 000000000..adaa8a0fe --- /dev/null +++ b/backend/flow_api/flow/device_trust/hook_schedule_trust_device_state.go @@ -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 +} diff --git a/backend/flow_api/flow/flows.go b/backend/flow_api/flow/flows.go index 81fbdb20d..340fc6870 100644 --- a/backend/flow_api/flow/flows.go +++ b/backend/flow_api/flow/flows.go @@ -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{}). diff --git a/backend/flow_api/flow/login/hook_schedule_onboarding_states.go b/backend/flow_api/flow/login/hook_schedule_onboarding_states.go index 9d6454bbb..7d9e052c7 100644 --- a/backend/flow_api/flow/login/hook_schedule_onboarding_states.go +++ b/backend/flow_api/flow/login/hook_schedule_onboarding_states.go @@ -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() diff --git a/backend/flow_api/flow/mfa_creation/action_otp_code_verify.go b/backend/flow_api/flow/mfa_creation/action_otp_code_verify.go index aaefd6cf5..353a6cee2 100644 --- a/backend/flow_api/flow/mfa_creation/action_otp_code_verify.go +++ b/backend/flow_api/flow/mfa_creation/action_otp_code_verify.go @@ -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 { diff --git a/backend/flow_api/flow/registration/action_register_login_identifier.go b/backend/flow_api/flow/registration/action_register_login_identifier.go index d0b4dcd96..3483ec814 100644 --- a/backend/flow_api/flow/registration/action_register_login_identifier.go +++ b/backend/flow_api/flow/registration/action_register_login_identifier.go @@ -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 } diff --git a/backend/flow_api/flow/registration/hook_create_user.go b/backend/flow_api/flow/registration/hook_create_user.go index d275d553d..4c3523536 100644 --- a/backend/flow_api/flow/registration/hook_create_user.go +++ b/backend/flow_api/flow/registration/hook_create_user.go @@ -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( diff --git a/backend/flow_api/flow/shared/const_action_names.go b/backend/flow_api/flow/shared/const_action_names.go index 93bab9323..b7b77dde5 100644 --- a/backend/flow_api/flow/shared/const_action_names.go +++ b/backend/flow_api/flow/shared/const_action_names.go @@ -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" diff --git a/backend/flow_api/flow/shared/const_flow_names.go b/backend/flow_api/flow/shared/const_flow_names.go index 5511c6956..f463e793c 100644 --- a/backend/flow_api/flow/shared/const_flow_names.go +++ b/backend/flow_api/flow/shared/const_flow_names.go @@ -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" diff --git a/backend/flow_api/flow/shared/const_stash_paths.go b/backend/flow_api/flow/shared/const_stash_paths.go index 12eb2a8ce..4bde726af 100644 --- a/backend/flow_api/flow/shared/const_stash_paths.go +++ b/backend/flow_api/flow/shared/const_stash_paths.go @@ -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" diff --git a/backend/flow_api/flow/shared/const_state_names.go b/backend/flow_api/flow/shared/const_state_names.go index b8d7d1a88..a8b707de0 100644 --- a/backend/flow_api/flow/shared/const_state_names.go +++ b/backend/flow_api/flow/shared/const_state_names.go @@ -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" diff --git a/backend/flow_api/flow/shared/hook_issue_session.go b/backend/flow_api/flow/shared/hook_issue_session.go index db659d9f7..df0c5463f 100644 --- a/backend/flow_api/flow/shared/hook_issue_session.go +++ b/backend/flow_api/flow/shared/hook_issue_session.go @@ -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,25 @@ 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) + } + + if deps.Cfg.Session.Cookie.Retention == "session" || + (deps.Cfg.Session.Cookie.Retention == "prompt" && !rememberMeSelected) { + // Issue a session cookie. + cookie.MaxAge = 0 + } + + deps.HttpContext.Response().Header().Set("X-Session-Lifetime", fmt.Sprintf("%d", int(lifespan.Seconds()))) + deps.HttpContext.Response().Header().Set("X-Remember-Me", fmt.Sprintf("%t", rememberMeSelected)) if deps.Cfg.Session.EnableAuthTokenHeader { deps.HttpContext.Response().Header().Set("X-Auth-Token", signedSessionToken) diff --git a/backend/flow_api/services/device_trust.go b/backend/flow_api/services/device_trust.go new file mode 100644 index 000000000..f79ee9dfd --- /dev/null +++ b/backend/flow_api/services/device_trust.go @@ -0,0 +1,71 @@ +package services + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "github.com/gofrs/uuid" + "github.com/labstack/echo/v4" + "github.com/teamhanko/hanko/backend/config" + "github.com/teamhanko/hanko/backend/persistence" + "github.com/teamhanko/hanko/backend/persistence/models" + "time" +) + +type DeviceTrustService struct { + Persister persistence.TrustedDevicePersister + Cfg config.Config + HttpContext echo.Context +} + +func (s DeviceTrustService) CreateTrustedDevice(userID uuid.UUID, deviceToken string) error { + deviceID, err := uuid.NewV4() + if err != nil { + return fmt.Errorf("failed to generate device id: %w", err) + } + + trustedDeviceModel := models.TrustedDevice{ + ID: deviceID, + UserID: userID, + DeviceToken: deviceToken, + ExpiresAt: time.Now().Add(s.Cfg.MFA.DeviceTrustDuration).UTC(), + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + + err = s.Persister.Create(trustedDeviceModel) + if err != nil { + return fmt.Errorf("failed to store trusted device: %w", err) + } + + return nil +} + +func (s DeviceTrustService) CheckDeviceTrust(userID uuid.UUID) bool { + if !userID.IsNil() && s.Cfg.MFA.DeviceTrustPolicy != "never" { + cookieName := s.Cfg.MFA.DeviceTrustCookieName + cookie, _ := s.HttpContext.Cookie(cookieName) + + if cookie != nil { + deviceToken := cookie.Value + trustedDeviceModel, err := s.Persister.FindByDeviceToken(deviceToken) + + if err == nil && trustedDeviceModel != nil && + time.Now().UTC().Before(trustedDeviceModel.ExpiresAt.UTC()) && + trustedDeviceModel.UserID.String() == userID.String() { + return true + } + } + } + + return false +} + +func (s DeviceTrustService) GenerateRandomToken(length int) (string, error) { + bytes := make([]byte, length) + _, err := rand.Read(bytes) + if err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(bytes), nil +} diff --git a/backend/handler/public_router.go b/backend/handler/public_router.go index 88d5dcdb8..ff6ecdc47 100644 --- a/backend/handler/public_router.go +++ b/backend/handler/public_router.go @@ -103,6 +103,7 @@ func NewPublicRouter(cfg *config.Config, persister persistence.Persister, promet httplimit.HeaderRateLimitRemaining, httplimit.HeaderRateLimitReset, "X-Session-Lifetime", + "X-Remember-Me", } if cfg.Session.EnableAuthTokenHeader { diff --git a/backend/persistence/migrations/20241112181011_create_trusted_devices.down.fizz b/backend/persistence/migrations/20241112181011_create_trusted_devices.down.fizz new file mode 100644 index 000000000..2ae998542 --- /dev/null +++ b/backend/persistence/migrations/20241112181011_create_trusted_devices.down.fizz @@ -0,0 +1 @@ +drop_table("trusted_devices") diff --git a/backend/persistence/migrations/20241112181011_create_trusted_devices.up.fizz b/backend/persistence/migrations/20241112181011_create_trusted_devices.up.fizz new file mode 100644 index 000000000..767c9afc4 --- /dev/null +++ b/backend/persistence/migrations/20241112181011_create_trusted_devices.up.fizz @@ -0,0 +1,8 @@ +create_table("trusted_devices") { + t.Column("id", "uuid", {primary: true}) + t.Column("user_id", "uuid", { "null": false }) + t.Column("device_token", "string", { "null": false }) + t.Column("expires_at", "timestamp", {}) + t.Timestamps() + t.ForeignKey("user_id", {"users": ["id"]}, {"on_delete": "cascade", "on_update": "cascade"}) +} diff --git a/backend/persistence/models/trusted_device.go b/backend/persistence/models/trusted_device.go new file mode 100644 index 000000000..eb4dbba71 --- /dev/null +++ b/backend/persistence/models/trusted_device.go @@ -0,0 +1,29 @@ +package models + +import ( + "github.com/gobuffalo/pop/v6" + "github.com/gobuffalo/validate/v3" + "github.com/gobuffalo/validate/v3/validators" + "github.com/gofrs/uuid" + "time" +) + +type TrustedDevice struct { + ID uuid.UUID `db:"id"` + UserID uuid.UUID `db:"user_id"` + DeviceToken string `db:"device_token"` + ExpiresAt time.Time `db:"expires_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +func (trustedDevice *TrustedDevice) Validate(tx *pop.Connection) (*validate.Errors, error) { + return validate.Validate( + &validators.UUIDIsPresent{Name: "ID", Field: trustedDevice.ID}, + &validators.UUIDIsPresent{Name: "UserID", Field: trustedDevice.UserID}, + &validators.StringIsPresent{Name: "DeviceToken", Field: trustedDevice.DeviceToken}, + &validators.TimeIsPresent{Name: "ExpiresAt", Field: trustedDevice.ExpiresAt}, + &validators.TimeIsPresent{Name: "UpdatedAt", Field: trustedDevice.UpdatedAt}, + &validators.TimeIsPresent{Name: "CreatedAt", Field: trustedDevice.CreatedAt}, + ), nil +} diff --git a/backend/persistence/persister.go b/backend/persistence/persister.go index 5350fb07c..cd7a21fdf 100644 --- a/backend/persistence/persister.go +++ b/backend/persistence/persister.go @@ -44,6 +44,8 @@ type Persister interface { GetWebauthnSessionDataPersister() WebauthnSessionDataPersister GetWebauthnSessionDataPersisterWithConnection(tx *pop.Connection) WebauthnSessionDataPersister GetWebhookPersister(tx *pop.Connection) WebhookPersister + GetTrustedDevicePersister() TrustedDevicePersister + GetTrustedDevicePersisterWithConnection(tx *pop.Connection) TrustedDevicePersister GetUsernamePersister() UsernamePersister GetUsernamePersisterWithConnection(tx *pop.Connection) UsernamePersister GetSessionPersister() SessionPersister @@ -159,6 +161,14 @@ func (p *persister) GetPasswordCredentialPersisterWithConnection(tx *pop.Connect return NewPasswordCredentialPersister(tx) } +func (p *persister) GetTrustedDevicePersister() TrustedDevicePersister { + return NewTrustedDevicePersister(p.DB) +} + +func (p *persister) GetTrustedDevicePersisterWithConnection(tx *pop.Connection) TrustedDevicePersister { + return NewTrustedDevicePersister(tx) +} + func (p *persister) GetUsernamePersister() UsernamePersister { return NewUsernamePersister(p.DB) } diff --git a/backend/persistence/trusted_device_persister.go b/backend/persistence/trusted_device_persister.go new file mode 100644 index 000000000..4c3e30a02 --- /dev/null +++ b/backend/persistence/trusted_device_persister.go @@ -0,0 +1,47 @@ +package persistence + +import ( + "database/sql" + "errors" + "fmt" + "github.com/gobuffalo/pop/v6" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type TrustedDevicePersister interface { + Create(models.TrustedDevice) error + FindByDeviceToken(string) (*models.TrustedDevice, error) +} + +type trustedDevicePersister struct { + db *pop.Connection +} + +func NewTrustedDevicePersister(db *pop.Connection) TrustedDevicePersister { + return &trustedDevicePersister{db: db} +} + +func (p *trustedDevicePersister) Create(trustedDevice models.TrustedDevice) error { + vErr, err := p.db.ValidateAndCreate(&trustedDevice) + if err != nil { + return fmt.Errorf("failed to store trustedDevice: %w", err) + } + if vErr != nil && vErr.HasAny() { + return fmt.Errorf("trustedDevice object validation failed: %w", vErr) + } + + return nil +} + +func (p *trustedDevicePersister) FindByDeviceToken(token string) (*models.TrustedDevice, error) { + trustedDevice := models.TrustedDevice{} + err := p.db.Where("device_token = ?", token).First(&trustedDevice) + if err != nil && errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get trustedDevice: %w", err) + } + + return &trustedDevice, nil +} diff --git a/frontend/elements/src/contexts/AppProvider.tsx b/frontend/elements/src/contexts/AppProvider.tsx index a1c9e86b8..92ea3b3a5 100644 --- a/frontend/elements/src/contexts/AppProvider.tsx +++ b/frontend/elements/src/contexts/AppProvider.tsx @@ -60,6 +60,7 @@ import LoginSecurityKeyPage from "../pages/LoginSecurityKeyPage"; import MFAMethodChooserPage from "../pages/MFAMethodChooserPage"; import CreateOTPSecretPage from "../pages/CreateOTPSecretPage"; import CreateSecurityKeyPage from "../pages/CreateSecurityKeyPage"; +import DeviceTrustPage from "../pages/DeviceTrustPage"; import SignalLike = JSXInternal.SignalLike; @@ -113,7 +114,8 @@ export type UIAction = | "thirdparty-submit" | "session-delete" | "auth-app-add" - | "auth-app-remove"; + | "auth-app-remove" + | "trust-device-submit"; interface UIState { username?: string; @@ -489,6 +491,9 @@ const AppProvider = ({ await hanko.user.logout(); hanko.relay.dispatchUserDeletedEvent(); }, + device_trust(state) { + setPage(); + }, }), [ globalOptions.enablePasskeys, diff --git a/frontend/elements/src/i18n/bn.ts b/frontend/elements/src/i18n/bn.ts index 36bf51a51..3e16de4ca 100644 --- a/frontend/elements/src/i18n/bn.ts +++ b/frontend/elements/src/i18n/bn.ts @@ -47,6 +47,7 @@ export const bn: Translation = { authenticatorApp: "প্রমাণীকরণ অ্যাপ", authenticatorAppAlreadySetUp: "প্রমাণীকরণ অ্যাপ সেট আপ করা হয়েছে", authenticatorAppNotSetUp: "প্রমাণীকরণ অ্যাপ সেট আপ করুন", + trustDevice: "এই ব্রাউজারটি বিশ্বাস করবেন?", }, texts: { enterPasscode: 'যে পাসকোডটি পাঠানো হয়েছিল "{emailAddress}" এ তা লিখুন.', @@ -91,6 +92,8 @@ export const bn: Translation = { authenticatorAppNotSetUp: "মাল্টি-ফ্যাক্টর প্রমাণীকরণের জন্য সময়-ভিত্তিক এককালীন পাসওয়ার্ড (TOTP) তৈরি করার জন্য একটি প্রমাণীকরণ অ্যাপ দ্বারা আপনার অ্যাকাউন্টটি সুরক্ষিত করুন।", securityKeySetUp: "নিরাপত্তা কী যুক্ত করুন", + trustDevice: + "যদি আপনি এই ব্রাউজারটিকে বিশ্বাস করেন, তবে পরেরবার লগইন করার সময় আপনাকে আপনার OTP (ওয়ান-টাইম-পাসওয়ার্ড) প্রবেশ করাতে হবে না বা মাল্টি-ফ্যাক্টর অথেনটিকেশন (MFA) এর জন্য আপনার সিকিউরিটি কী ব্যবহার করতে হবে না।", }, labels: { or: "বা", @@ -149,6 +152,8 @@ export const bn: Translation = { authenticatorAppAdd: "সেট আপ করুন", configured: "কনফিগার করা হয়েছে", useAnotherMethod: "আরেকটি পদ্ধতি ব্যবহার করুন", + trustDevice: "এই ব্রাউজারটি বিশ্বাস করবেন", + staySignedIn: "সাইন ইন থাকা চালিয়ে যান", }, errors: { somethingWentWrong: diff --git a/frontend/elements/src/i18n/de.ts b/frontend/elements/src/i18n/de.ts index dd12a77e0..d5b039899 100644 --- a/frontend/elements/src/i18n/de.ts +++ b/frontend/elements/src/i18n/de.ts @@ -47,6 +47,7 @@ export const de: Translation = { authenticatorApp: "Authenticator-App", authenticatorAppNotSetUp: "Authenticator-App einrichten", authenticatorAppAlreadySetUp: "Authenticator-App ist eingerichtet", + trustDevice: "Diesem Browser vertrauen?", }, texts: { enterPasscode: @@ -97,6 +98,8 @@ export const de: Translation = { "Schützen Sie Ihr Konto mit einer Authenticator-App, die zeitbasierte einmalige Passwörter (TOTP) für die Mehrfaktor-Authentifizierung generiert.", securityKeySetUp: "Verwenden Sie einen dedizierten Sicherheitsschlüssel über USB, Bluetooth oder NFC oder Ihr Mobiltelefon. Schließen Sie Ihren Sicherheitsschlüssel an oder aktivieren Sie ihn, und klicken Sie dann auf die Schaltfläche unten und folgen Sie den Anweisungen, um die Registrierung abzuschließen.", + trustDevice: + "Wenn Sie diesem Browser vertrauen, müssen Sie bei der nächsten Anmeldung weder Ihr OTP (Einmalpasswort) eingeben noch Ihren Sicherheitsschlüssel für die Multi-Faktor-Authentifizierung (MFA) verwenden.", }, labels: { or: "oder", @@ -155,6 +158,8 @@ export const de: Translation = { authenticatorAppAdd: "Einrichten", configured: "konfiguriert", useAnotherMethod: "Eine andere Methode verwenden", + trustDevice: "Diesem Browser vertrauen", + staySignedIn: "Angemeldet bleiben", }, errors: { somethingWentWrong: diff --git a/frontend/elements/src/i18n/en.ts b/frontend/elements/src/i18n/en.ts index e2f5da77a..662c763a7 100644 --- a/frontend/elements/src/i18n/en.ts +++ b/frontend/elements/src/i18n/en.ts @@ -47,6 +47,7 @@ export const en: Translation = { authenticatorApp: "Authenticator app", authenticatorAppAlreadySetUp: "Authenticator app is set up", authenticatorAppNotSetUp: "Set up authenticator app", + trustDevice: "Trust this browser?", }, texts: { enterPasscode: 'Enter the passcode that was sent to "{emailAddress}".', @@ -92,6 +93,8 @@ export const en: Translation = { "Your account is secured with an authenticator app that generates time-based one-time passwords (TOTP) for multi-factor authentication.", authenticatorAppNotSetUp: "Secure your account with an authenticator app that generates time-based one-time passwords (TOTP) for multi-factor authentication.", + trustDevice: + "If you trust this browser, you won’t need to enter your OTP (One-Time-Password) or use your security key for multi-factor authentication (MFA) the next time you log in.", }, labels: { or: "or", @@ -150,6 +153,8 @@ export const en: Translation = { authenticatorAppAdd: "Set up", configured: "configured", useAnotherMethod: "Use another method", + trustDevice: "Trust this browser", + staySignedIn: "Stay signed in", }, errors: { somethingWentWrong: diff --git a/frontend/elements/src/i18n/fr.ts b/frontend/elements/src/i18n/fr.ts index cab8e5fa2..486ad0ff7 100644 --- a/frontend/elements/src/i18n/fr.ts +++ b/frontend/elements/src/i18n/fr.ts @@ -48,6 +48,7 @@ export const fr: Translation = { authenticatorAppAlreadySetUp: "L'application d'authentification est configurée", authenticatorAppNotSetUp: "Configurer l'application d'authentification", + trustDevice: "Faire confiance à ce navigateur ?", }, texts: { enterPasscode: @@ -96,6 +97,8 @@ export const fr: Translation = { "Sécurisez votre compte avec une application d'authentification qui génère des mots de passe à usage unique basés sur le temps (TOTP) pour l'authentification à plusieurs facteurs.", securityKeySetUp: "Utilisez une clé de sécurité dédiée via USB, Bluetooth ou NFC, ou votre téléphone mobile. Connectez ou activez votre clé de sécurité, puis cliquez sur le bouton ci-dessous et suivez les instructions pour terminer l'enregistrement.", + trustDevice: + "Si vous faites confiance à ce navigateur, vous n'aurez pas besoin de saisir votre OTP (mot de passe à usage unique) ou d'utiliser votre clé de sécurité pour l'authentification à plusieurs facteurs (MFA) lors de votre prochaine connexion.", }, labels: { or: "ou", @@ -155,6 +158,8 @@ export const fr: Translation = { authenticatorAppAdd: "Configurer", configured: "configuré", useAnotherMethod: "Utiliser une autre méthode", + trustDevice: "Faire confiance à ce navigateur", + staySignedIn: "Rester connecté", }, errors: { somethingWentWrong: diff --git a/frontend/elements/src/i18n/it.ts b/frontend/elements/src/i18n/it.ts index 492fc5eb6..4a8d9fa12 100644 --- a/frontend/elements/src/i18n/it.ts +++ b/frontend/elements/src/i18n/it.ts @@ -47,6 +47,7 @@ export const it: Translation = { authenticatorApp: "App di autenticazione", authenticatorAppAlreadySetUp: "L'app di autenticazione è già configurata", authenticatorAppNotSetUp: "Imposta l'app di autenticazione", + trustDevice: "Fidarsi di questo browser?", }, texts: { enterPasscode: 'Inserisci il codice di accesso inviato a "{emailAddress}".', @@ -93,6 +94,8 @@ export const it: Translation = { "Proteggi il tuo account con un'app di autenticazione che genera codici monouso (TOTP) per l'autenticazione a più fattori.", securityKeySetUp: "Utilizza una chiave di sicurezza dedicata tramite USB, Bluetooth o NFC oppure il tuo telefono. Collega la tua chiave di sicurezza o attivala, quindi fai clic sul pulsante qui sotto e segui le istruzioni per completare la registrazione.", + trustDevice: + "Se ti fidi di questo browser, non dovrai inserire il tuo OTP (One-Time Password) o utilizzare la tua chiave di sicurezza per l'autenticazione a più fattori (MFA) la prossima volta che accedi.", }, labels: { or: "o", @@ -151,6 +154,8 @@ export const it: Translation = { authenticatorAppAdd: "Imposta", configured: "configurato", useAnotherMethod: "Usa un altro metodo", + trustDevice: "Fidarsi di questo browser", + staySignedIn: "Rimani connesso", }, errors: { somethingWentWrong: "Si è verificato un errore tecnico. Riprova più tardi.", diff --git a/frontend/elements/src/i18n/pt-BR.ts b/frontend/elements/src/i18n/pt-BR.ts index 198bb037c..396cff60c 100644 --- a/frontend/elements/src/i18n/pt-BR.ts +++ b/frontend/elements/src/i18n/pt-BR.ts @@ -48,6 +48,7 @@ export const ptBR: Translation = { authenticatorAppAlreadySetUp: "O aplicativo de autenticação já está configurado", authenticatorAppNotSetUp: "Configurar o aplicativo de autenticação", + trustDevice: "Confiar neste navegador?", }, texts: { enterPasscode: @@ -95,6 +96,8 @@ export const ptBR: Translation = { "Proteja sua conta com um aplicativo de autenticação que gera códigos únicos (TOTP) para autenticação de múltiplos fatores.", securityKeySetUp: "Use uma chave de segurança dedicada via USB, Bluetooth ou NFC ou seu telefone. Conecte sua chave de segurança ou ative-a, em seguida, clique no botão abaixo e siga as instruções para concluir o registro.", + trustDevice: + "Se você confiar neste navegador, não precisará digitar seu OTP (Senha Única) ou usar sua chave de segurança para autenticação multifator (MFA) na próxima vez que fizer login.", }, labels: { or: "ou", @@ -154,6 +157,8 @@ export const ptBR: Translation = { authenticatorAppAdd: "Configurar", configured: "configurado", useAnotherMethod: "Usar outro método", + trustDevice: "Confiar neste navegador", + staySignedIn: "Manter-me conectado", }, errors: { somethingWentWrong: diff --git a/frontend/elements/src/i18n/translations.ts b/frontend/elements/src/i18n/translations.ts index 8574f55a0..ef076b4df 100644 --- a/frontend/elements/src/i18n/translations.ts +++ b/frontend/elements/src/i18n/translations.ts @@ -21,6 +21,7 @@ export interface Translation { registerPassword: string; securityKeyLogin: string; securityKeySetUp: string; + trustDevice: string; mfaSetUp: string; otpLogin: string; otpSetUp: string; @@ -67,6 +68,7 @@ export interface Translation { otpSecretKey: string; passwordFormatHint: string; securityKeyLogin: string; + trustDevice: string; isPrimaryEmail: string; securityKeySetUp: string; setPrimaryEmail: string; @@ -108,6 +110,8 @@ export interface Translation { signInPasskey: string; signUp: string; sendNewPasscode: string; + staySignedIn: string; + trustDevice: string; passwordRetryAfter: string; passcodeResendAfter: string; useAnotherMethod: string; diff --git a/frontend/elements/src/i18n/zh.ts b/frontend/elements/src/i18n/zh.ts index f70c7d195..c1bdd3506 100644 --- a/frontend/elements/src/i18n/zh.ts +++ b/frontend/elements/src/i18n/zh.ts @@ -47,6 +47,7 @@ export const zh: Translation = { authenticatorApp: "身份验证应用", authenticatorAppAlreadySetUp: "身份验证应用已设置", authenticatorAppNotSetUp: "设置身份验证应用", + trustDevice: "信任此浏览器?", }, texts: { enterPasscode: "输入发送到“{emailAddress}”的验证码。", @@ -88,6 +89,8 @@ export const zh: Translation = { "使用生成基于时间的一次性密码 (TOTP) 的身份验证应用保护您的账户以实现多因素认证。", securityKeySetUp: "通过 USB、蓝牙或 NFC 使用专用安全密钥,或使用手机。连接或激活您的安全密钥,然后点击下面的按钮,按照提示完成注册。", + trustDevice: + "如果您信任此浏览器,下次登录时您无需输入一次性密码(OTP)或使用您的安全密钥进行多因素认证(MFA)。", }, labels: { or: "或", @@ -146,6 +149,8 @@ export const zh: Translation = { authenticatorAppAdd: "设置", configured: "已配置", useAnotherMethod: "使用其他方法", + trustDevice: "信任此浏览器", + staySignedIn: "保持登录状态", }, errors: { somethingWentWrong: "发生技术错误。请稍后再试。", diff --git a/frontend/elements/src/pages/DeviceTrustPage.tsx b/frontend/elements/src/pages/DeviceTrustPage.tsx new file mode 100644 index 000000000..89d971a11 --- /dev/null +++ b/frontend/elements/src/pages/DeviceTrustPage.tsx @@ -0,0 +1,86 @@ +import { Fragment } from "preact"; +import { useContext } from "preact/compat"; +import { TranslateContext } from "@denysvuika/preact-translate"; +import { AppContext } from "../contexts/AppProvider"; + +import Content from "../components/wrapper/Content"; +import Form from "../components/form/Form"; +import Button from "../components/form/Button"; +import ErrorBox from "../components/error/ErrorBox"; +import Headline1 from "../components/headline/Headline1"; + +import { State } from "@teamhanko/hanko-frontend-sdk/dist/lib/flow-api/State"; + +import { useFlowState } from "../contexts/FlowState"; +import Paragraph from "../components/paragraph/Paragraph"; +import Footer from "../components/wrapper/Footer"; +import Link from "../components/link/Link"; + +interface Props { + state: State<"device_trust">; +} + +const DeviceTrustPage = (props: Props) => { + const { t } = useContext(TranslateContext); + const { hanko, setLoadingAction, stateHandler } = useContext(AppContext); + const { flowState } = useFlowState(props.state); + + const onTrustDeviceSubmit = async (event: Event) => { + event.preventDefault(); + setLoadingAction("trust-device-submit"); + const nextState = await flowState.actions.trust_device(null).run(); + setLoadingAction(null); + await hanko.flow.run(nextState, stateHandler); + }; + + const onSkipClick = async (event: Event) => { + event.preventDefault(); + setLoadingAction("skip"); + const nextState = await flowState.actions.skip(null).run(); + setLoadingAction(null); + await hanko.flow.run(nextState, stateHandler); + }; + + const onBackClick = async (event: Event) => { + event.preventDefault(); + setLoadingAction("back"); + const nextState = await flowState.actions.back(null).run(); + setLoadingAction(null); + await hanko.flow.run(nextState, stateHandler); + }; + + return ( + + + {t("headlines.trustDevice")} + + {t("texts.trustDevice")} +
+ +
+
+
+ + {t("labels.back")} + + + {t("labels.skip")} + +
+
+ ); +}; + +export default DeviceTrustPage; diff --git a/frontend/elements/src/pages/LoginInitPage.tsx b/frontend/elements/src/pages/LoginInitPage.tsx index 0b4b1da93..c18cbcd38 100644 --- a/frontend/elements/src/pages/LoginInitPage.tsx +++ b/frontend/elements/src/pages/LoginInitPage.tsx @@ -22,6 +22,8 @@ import ErrorBox from "../components/error/ErrorBox"; import Headline1 from "../components/headline/Headline1"; import Link from "../components/link/Link"; import Footer from "../components/wrapper/Footer"; +import Checkbox from "../components/form/Checkbox"; +import Spacer from "../components/spacer/Spacer"; interface Props { state: State<"login_init">; @@ -51,6 +53,7 @@ const LoginInitPage = (props: Props) => { const [thirdPartyError, setThirdPartyError] = useState< HankoError | undefined >(undefined); + const [rememberMe, setRememberMe] = useState(false); const onIdentifierInput = (event: Event) => { event.preventDefault(); @@ -92,6 +95,14 @@ const LoginInitPage = (props: Props) => { init("registration"); }; + const onRememberMeChange = async (event: Event) => { + const nextState = await flowState.actions + .remember_me({ remember_me: !rememberMe }) + .run(); + setRememberMe((prev) => !prev); + await hanko.flow.run(nextState, stateHandler); + }; + const setIdentifierToUIState = (value: string) => { const setEmail = () => setUIState((prev) => ({ ...prev, email: value, username: null })); @@ -265,6 +276,18 @@ const LoginInitPage = (props: Props) => { ); }) : null} + {flowState.actions.remember_me?.(null) && ( + + + + + )}