mirror of
https://github.com/teamhanko/hanko.git
synced 2025-10-27 14:17:56 +08:00
feat: trusted devices and 'remember me' (#1982)
This commit is contained in:
@ -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:
|
||||
|
||||
@ -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,
|
||||
@ -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{
|
||||
|
||||
@ -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"`
|
||||
// `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
|
||||
@ -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.",
|
||||
}}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
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)
|
||||
|
||||
71
backend/flow_api/services/device_trust.go
Normal file
71
backend/flow_api/services/device_trust.go
Normal file
@ -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
|
||||
}
|
||||
@ -103,6 +103,7 @@ func NewPublicRouter(cfg *config.Config, persister persistence.Persister, promet
|
||||
httplimit.HeaderRateLimitRemaining,
|
||||
httplimit.HeaderRateLimitReset,
|
||||
"X-Session-Lifetime",
|
||||
"X-Session-Retention",
|
||||
}
|
||||
|
||||
if cfg.Session.EnableAuthTokenHeader {
|
||||
|
||||
@ -0,0 +1 @@
|
||||
drop_table("trusted_devices")
|
||||
@ -0,0 +1,9 @@
|
||||
create_table("trusted_devices") {
|
||||
t.Column("id", "uuid", {primary: true})
|
||||
t.Column("user_id", "uuid", { "null": false })
|
||||
t.Column("device_token", "string", { "null": false, "size": 128 })
|
||||
t.Column("expires_at", "timestamp", {})
|
||||
t.Timestamps()
|
||||
t.Index("device_token")
|
||||
t.ForeignKey("user_id", {"users": ["id"]}, {"on_delete": "cascade", "on_update": "cascade"})
|
||||
}
|
||||
30
backend/persistence/models/trusted_device.go
Normal file
30
backend/persistence/models/trusted_device.go
Normal file
@ -0,0 +1,30 @@
|
||||
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.StringLengthInRange{Name: "DeviceToken", Field: trustedDevice.DeviceToken, Min: 64, Max: 128},
|
||||
&validators.TimeIsPresent{Name: "ExpiresAt", Field: trustedDevice.ExpiresAt},
|
||||
&validators.TimeIsPresent{Name: "UpdatedAt", Field: trustedDevice.UpdatedAt},
|
||||
&validators.TimeIsPresent{Name: "CreatedAt", Field: trustedDevice.CreatedAt},
|
||||
), nil
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
47
backend/persistence/trusted_device_persister.go
Normal file
47
backend/persistence/trusted_device_persister.go
Normal file
@ -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
|
||||
}
|
||||
@ -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(<DeviceTrustPage state={state} />);
|
||||
},
|
||||
}),
|
||||
[
|
||||
globalOptions.enablePasskeys,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: "发生技术错误。请稍后再试。",
|
||||
|
||||
86
frontend/elements/src/pages/DeviceTrustPage.tsx
Normal file
86
frontend/elements/src/pages/DeviceTrustPage.tsx
Normal file
@ -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 (
|
||||
<Fragment>
|
||||
<Content>
|
||||
<Headline1>{t("headlines.trustDevice")}</Headline1>
|
||||
<ErrorBox flowError={flowState?.error} />
|
||||
<Paragraph>{t("texts.trustDevice")}</Paragraph>
|
||||
<Form onSubmit={onTrustDeviceSubmit}>
|
||||
<Button uiAction={"trust-device-submit"}>
|
||||
{t("labels.trustDevice")}
|
||||
</Button>
|
||||
</Form>
|
||||
</Content>
|
||||
<Footer>
|
||||
<Link
|
||||
uiAction={"back"}
|
||||
onClick={onBackClick}
|
||||
loadingSpinnerPosition={"right"}
|
||||
hidden={!flowState.actions.back?.(null)}
|
||||
>
|
||||
{t("labels.back")}
|
||||
</Link>
|
||||
<Link
|
||||
uiAction={"skip"}
|
||||
onClick={onSkipClick}
|
||||
loadingSpinnerPosition={"left"}
|
||||
hidden={!flowState.actions.skip?.(null)}
|
||||
>
|
||||
{t("labels.skip")}
|
||||
</Link>
|
||||
</Footer>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceTrustPage;
|
||||
@ -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<boolean>(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) && (
|
||||
<Fragment>
|
||||
<Spacer />
|
||||
<Checkbox
|
||||
required={false}
|
||||
type={"checkbox"}
|
||||
label={t("labels.staySignedIn")}
|
||||
checked={rememberMe}
|
||||
onChange={onRememberMeChange}
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
</Content>
|
||||
<Footer hidden={initialComponentName !== "auth"}>
|
||||
<span hidden />
|
||||
|
||||
@ -17,6 +17,8 @@ import Link from "../components/link/Link";
|
||||
import Input from "../components/form/Input";
|
||||
import { HankoError } from "@teamhanko/hanko-frontend-sdk";
|
||||
import Divider from "../components/spacer/Divider";
|
||||
import Checkbox from "../components/form/Checkbox";
|
||||
import Spacer from "../components/spacer/Spacer";
|
||||
|
||||
interface Props {
|
||||
state: State<"registration_init">;
|
||||
@ -39,6 +41,7 @@ const RegistrationInitPage = (props: Props) => {
|
||||
const [thirdPartyError, setThirdPartyError] = useState<
|
||||
HankoError | undefined
|
||||
>(undefined);
|
||||
const [rememberMe, setRememberMe] = useState<boolean>(false);
|
||||
|
||||
const onIdentifierSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
@ -87,6 +90,14 @@ const RegistrationInitPage = (props: Props) => {
|
||||
await hanko.flow.run(nextState, stateHandler);
|
||||
};
|
||||
|
||||
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 showDivider = useMemo(
|
||||
() => !!flowState.actions.thirdparty_oauth?.(null),
|
||||
[flowState.actions],
|
||||
@ -192,6 +203,18 @@ const RegistrationInitPage = (props: Props) => {
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{flowState.actions.remember_me?.(null) && (
|
||||
<Fragment>
|
||||
<Spacer />
|
||||
<Checkbox
|
||||
required={false}
|
||||
type={"checkbox"}
|
||||
label={t("labels.staySignedIn")}
|
||||
checked={rememberMe}
|
||||
onChange={onRememberMeChange}
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
</Content>
|
||||
<Footer hidden={initialComponentName !== "auth"}>
|
||||
<span hidden />
|
||||
|
||||
@ -196,6 +196,7 @@ class HttpClient {
|
||||
processHeaders(xhr: XMLHttpRequest) {
|
||||
let jwt = "";
|
||||
let expirationSeconds = 0;
|
||||
let retention = "";
|
||||
|
||||
xhr
|
||||
.getAllResponseHeaders()
|
||||
@ -209,6 +210,8 @@ class HttpClient {
|
||||
xhr.getResponseHeader("X-Session-Lifetime"),
|
||||
10,
|
||||
);
|
||||
} else if (header.startsWith("x-session-retention")) {
|
||||
retention = xhr.getResponseHeader("X-Session-Retention");
|
||||
}
|
||||
});
|
||||
|
||||
@ -216,7 +219,12 @@ class HttpClient {
|
||||
const https = new RegExp("^https://");
|
||||
const secure =
|
||||
!!this.api.match(https) && !!window.location.href.match(https);
|
||||
const expires = new Date(new Date().getTime() + expirationSeconds * 1000);
|
||||
|
||||
const expires =
|
||||
retention === "session"
|
||||
? undefined
|
||||
: new Date(new Date().getTime() + expirationSeconds * 1000);
|
||||
|
||||
this.cookie.setAuthCookie(jwt, { secure, expires });
|
||||
}
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
SessionDeleteInputs,
|
||||
OTPCodeInputs,
|
||||
SecurityKeyDeleteInputs,
|
||||
RememberMeInputs,
|
||||
} from "./input";
|
||||
|
||||
export interface Action<TInputs> {
|
||||
@ -38,6 +39,7 @@ export interface LoginInitActions {
|
||||
readonly webauthn_generate_request_options?: Action<null>;
|
||||
readonly webauthn_verify_assertion_response?: Action<WebauthnVerifyAssertionResponseInputs>;
|
||||
readonly thirdparty_oauth?: Action<ThirdpartyOauthInputs>;
|
||||
readonly remember_me?: Action<RememberMeInputs>;
|
||||
}
|
||||
|
||||
export interface ProfileInitActions {
|
||||
@ -126,6 +128,7 @@ export interface OnboardingVerifyPasskeyAttestationActions {
|
||||
export interface RegistrationInitActions {
|
||||
readonly register_login_identifier: Action<RegisterLoginIdentifierInputs>;
|
||||
readonly thirdparty_oauth?: Action<ThirdpartyOauthInputs>;
|
||||
readonly remember_me?: Action<RememberMeInputs>;
|
||||
}
|
||||
|
||||
export interface PasswordCreationActions {
|
||||
@ -157,6 +160,12 @@ export interface CredentialOnboardingChooserActions {
|
||||
readonly back: Action<null>;
|
||||
}
|
||||
|
||||
export interface DeviceTrustActions {
|
||||
readonly trust_device: Action<null>;
|
||||
readonly skip: Action<null>;
|
||||
readonly back?: Action<null>;
|
||||
}
|
||||
|
||||
export interface ThirdPartyActions {
|
||||
readonly exchange_token: Action<ExchangeTokenInputs>;
|
||||
}
|
||||
|
||||
@ -46,6 +46,7 @@ export interface RegisterClientCapabilitiesInputs {
|
||||
readonly webauthn_available: Input<boolean>;
|
||||
readonly webauthn_conditional_mediation_available: Input<boolean>;
|
||||
readonly webauthn_platform_authenticator_available: Input<boolean>;
|
||||
readonly trusted_device: Input<boolean>;
|
||||
}
|
||||
|
||||
export interface ContinueWithLoginIdentifierInputs {
|
||||
@ -104,6 +105,10 @@ export interface ThirdpartyOauthInputs {
|
||||
readonly redirect_to: Input<string>;
|
||||
}
|
||||
|
||||
export interface RememberMeInputs {
|
||||
readonly remember_me: Input<boolean>;
|
||||
}
|
||||
|
||||
export interface SessionDeleteInputs {
|
||||
readonly session_id: Input<string>;
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { State } from "../State";
|
||||
|
||||
import {
|
||||
CredentialOnboardingChooserActions,
|
||||
CredentialOnboardingChooserActions, DeviceTrustActions,
|
||||
LoginInitActions,
|
||||
LoginMethodChooserActions,
|
||||
LoginOTPActions,
|
||||
@ -38,6 +38,7 @@ import {
|
||||
export type StateName =
|
||||
| "account_deleted"
|
||||
| "credential_onboarding_chooser"
|
||||
| "device_trust"
|
||||
| "error"
|
||||
| "login_init"
|
||||
| "login_method_chooser"
|
||||
@ -65,6 +66,7 @@ export type StateName =
|
||||
export interface Actions {
|
||||
readonly account_deleted: null;
|
||||
readonly credential_onboarding_chooser: CredentialOnboardingChooserActions;
|
||||
readonly device_trust: DeviceTrustActions;
|
||||
readonly error: null;
|
||||
readonly login_init: LoginInitActions;
|
||||
readonly login_method_chooser: LoginMethodChooserActions;
|
||||
@ -93,6 +95,7 @@ export interface Actions {
|
||||
export interface Payloads {
|
||||
readonly account_deleted: null;
|
||||
readonly credential_onboarding_chooser: null;
|
||||
readonly device_trust: null;
|
||||
readonly error: null;
|
||||
readonly login_init: LoginInitPayload;
|
||||
readonly login_method_chooser: null;
|
||||
|
||||
Reference in New Issue
Block a user