Skip to content

Commit

Permalink
feat: introduce mfa (#1645)
Browse files Browse the repository at this point in the history
* feat: create otp_secrets table

* feat: create otp secret model

* feat: add mfa_only column to webauthn_credentials table

* feat: add mfa only field to webauthn credential model

* feat: add mfa config (#1607)

* feat: add otp secret persister (#1613)

* feat: MFA usage sub flow (#1614)

* feat: add mfa-usage sub-flow

---------

Co-authored-by: Lennart Fleischmann <[email protected]>

* feat: include platform authenticator availybility in the preflight flow (#1615)

* feat: add mfa creation subflow

* feat: adjust registration flow

* feat: integrate mfa usage sub-flow

* feat: add pages for mfa (#1622)

* feat: profile flow adjustments for mfa support

* fix: suspension logic for mfa deletion actions

* feat: use dedicated action for security key creation options

* fix: mfa method stash entry can be stale on profile flow

The mfa_creation subflow sets an mfa_method stash value so that
when creating and persisting the credential the mfa_only flag can
be set correctly in the hook responsible for that. But the profile flow
never "ends" and and returns to the initial state so I can also
register a passkey afterwards. The mfa_method stash key remains on the
stash but is used in the hook nonetheless, so the passkey is incorrectly
recognized as a security key.

The mfa_method key is now deleted after successfully persisting the
credential/security_key. This should not have an effect on the login
flow because the mfa_creation subflow is the last subflow to be
executed. It also should not affect the registration flow, because the
hook is not applied in the registration flow (persistence of data is
all handled in the create_user hook).

* feat: add new icons and english translations (#1626)

* fix: credential id encoding corrected (#1628)

* feat: add audit logs for mfa creation

* feat: add a skip link to the mfa method chooser (#1630)

* feat: save the security key during login (#1629)

* feat: show security keys in profile

* feat: add authenticator app management to profile (#1633)

* feat: add authenticator app management to profile
* feat: passkey counts as second factor

* feat: prohibit security key first factor usage

* feat: add all WA creds to exclude list on registration

* refactor: mfa stash entries and webauthn credential persistence

Renames MFA stash entry for indicating usage (login) method to make its
meaning more explicit. Also removes code persisting a webauthn credential
from the attestation verification action in the onboarding flow because
this is already done by a shared hook.

* refactor: simplify WA creation call

Co-authored-by: bjoern-m <[email protected]>

* chore: adjust mfa flow

* fix: mfa onboarding always shown during login

* fix: mfa onboarding not shown after password or email creation during login

* fix: mfa onboarding not shown without user detail onboarding

* fix: correct skip/back behaviour

* feat: reuse generated otp secret when the code is invalid

* chore: skip mfa prompt if the user only has a passkey

* chore: adjust login flow 

* chore: skip mfa prompt if the user only has a passkey

* chore: refactor and improve mfa onboarding

* fix: no mfa onboarding when passwords and passkeys are disabled

* fix: only show mfa onbooarding once

* feat: add a function to the flowpilot to check whether a state has been visited

* chore: adjust recovery flow (#1655)

* feat: disable password, passcode endpoints when mfa enabled

* Feat: remember last used login method (#1674)

* chore: remove omitempty from boolean (#1676)

* chore: improved error handling (#1679)

* chore: improved error handling

* feat: add missing translations (#1681)

* feat: update aaguid list (#1678)

* fix: do not suspend webauthn action for MFA (#1778)

Do not suspend the `webauthn_verify_attestation_response` action when passkeys are disabled, but security keys and MFA are enabled.

* fix: change texts (#1785)

Change texts regarding security creation to be more consistent across the flows and to be more precise.

* Fix: UI issues (#1846)

* fix: loading spinner alignment corrected

* fix: auth app deletion link is shown while deletion is not allowed

* Chore: remove test persister (#1876)

* chore: remove deprecated test persister

* chore: replace test persister calls

* chore: add saml state fixtures

* Update backend/flow_api/services/webauthn.go

Co-authored-by: Frederic Jahn <[email protected]>

* Update backend/dto/profile.go

Co-authored-by: Frederic Jahn <[email protected]>

* fix: otp validation uses the rate limiter key for passwords

* chore: add otp-limits to the default config

* chore: add explanation for 'UserVerification' setting on security keys

---------

Co-authored-by: Lennart Fleischmann <[email protected]>
Co-authored-by: Lennart Fleischmann <[email protected]>
Co-authored-by: Frederic Jahn <[email protected]>
  • Loading branch information
4 people authored Nov 1, 2024
1 parent 42b1c94 commit bc04b72
Show file tree
Hide file tree
Showing 156 changed files with 4,743 additions and 2,074 deletions.
2 changes: 2 additions & 0 deletions backend/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ type Config struct {
Emails Emails `yaml:"emails" json:"emails,omitempty" koanf:"emails" jsonschema:"title=emails"`
// `log` configures application logging.
Log LoggerConfig `yaml:"log" json:"log,omitempty" koanf:"log" jsonschema:"title=log"`
// `mfa` configures how multi-factor-authentication behaves.
MFA MFA `yaml:"mfa" json:"mfa,omitempty" koanf:"mfa" jsonschema:"title=mfa"`
// Deprecated. See child properties for suggested replacements.
Passcode Passcode `yaml:"passcode" json:"passcode,omitempty" koanf:"passcode" jsonschema:"title=passcode"`
// `passkey` configures how passkeys are acquired and used.
Expand Down
16 changes: 16 additions & 0 deletions backend/config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@ email_delivery:
port: "2500"
log:
log_health_and_metrics: true
mfa:
acquire_on_login: false
acquire_on_registration: true
enabled: true
optional: true
security_keys:
attestation_preference: direct
authenticator_attachment: cross-platform
enabled: true
limit: 10
user_verification: discouraged
totp:
enabled: true
passkey:
enabled: true
optional: true
Expand All @@ -47,6 +60,9 @@ password:
rate_limiter:
enabled: true
store: in_memory
otp_limits:
tokens: 3
interval: 1m
passcode_limits:
tokens: 3
interval: 1m
Expand Down
20 changes: 20 additions & 0 deletions backend/config/config_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ func DefaultConfig() *Config {
RateLimiter: RateLimiter{
Enabled: true,
Store: RATE_LIMITER_STORE_IN_MEMORY,
OTPLimits: RateLimits{
Tokens: 3,
Interval: 1 * time.Minute,
},
PasswordLimits: RateLimits{
Tokens: 5,
Interval: 1 * time.Minute,
Expand Down Expand Up @@ -161,6 +165,22 @@ func DefaultConfig() *Config {
MinLength: 3,
MaxLength: 32,
},
MFA: MFA{
AcquireOnLogin: false,
AcquireOnRegistration: true,
Enabled: true,
Optional: true,
SecurityKeys: SecurityKeys{
AttestationPreference: "direct",
AuthenticatorAttachment: "cross-platform",
Enabled: true,
Limit: 10,
UserVerification: "discouraged",
},
TOTP: TOTP{
Enabled: true,
},
},
Debug: false,
}
}
40 changes: 40 additions & 0 deletions backend/config/config_mfa.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package config

type SecurityKeys struct {
// `attestation_preference` is used to specify the preference regarding attestation conveyance during
// credential generation.
AttestationPreference string `yaml:"attestation_preference" json:"attestation_preference,omitempty" koanf:"attestation_preference" split_words:"true" jsonschema:"default=direct,enum=direct,enum=indirect,enum=none"`
// `authenticator_attachment` is used to specify the preference regarding authenticator attachment during credential registration.
AuthenticatorAttachment string `yaml:"authenticator_attachment" json:"authenticator_attachment,omitempty" koanf:"authenticator_attachment" split_words:"true" jsonschema:"default=cross-platform,enum=platform,enum=cross-platform,enum=no_preference"`
// `enabled` determines whether security keys are eligible for multi-factor-authentication.
Enabled bool `yaml:"enabled" json:"enabled" koanf:"enabled" jsonschema:"default=true"`
// 'limit' determines the maximum number of security keys a user can register.
Limit int `yaml:"limit" json:"limit,omitempty" koanf:"limit" jsonschema:"default=10"`
// `user_verification` specifies the requirements regarding local authorization with an authenticator through
// various authorization gesture modalities; for example, through a touch plus pin code,
// password entry, or biometric recognition.
//
// The setting applies to both WebAuthn registration and authentication ceremonies.
UserVerification string `yaml:"user_verification" json:"user_verification,omitempty" koanf:"user_verification" split_words:"true" jsonschema:"default=discouraged,enum=required,enum=preferred,enum=discouraged"`
}

type TOTP struct {
// `enabled` determines whether TOTP is eligible for multi-factor-authentication.
Enabled bool `yaml:"enabled" json:"enabled" koanf:"enabled" jsonschema:"default=true"`
}

type MFA struct {
// `acquire_on_login` configures if users are prompted creating an MFA credential on login.
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"`
// `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
// deleted if multi-factor-authentication is required (`optional: false`).
Optional bool `yaml:"optional" json:"optional" koanf:"optional" jsonschema:"default=true"`
// `security_keys` configures security key settings for multi-factor-authentication
SecurityKeys SecurityKeys `yaml:"security_keys" json:"security_keys,omitempty" koanf:"security_keys" jsonschema:"title=security_keys"`
// `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"`
}
2 changes: 2 additions & 0 deletions backend/config/config_rate_limiter.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ type RateLimiter struct {
Redis *RedisConfig `yaml:"redis_config" json:"redis_config,omitempty" koanf:"redis_config"`
// `passcode_limits` controls rate limits for passcode operations.
PasscodeLimits RateLimits `yaml:"passcode_limits" json:"passcode_limits,omitempty" koanf:"passcode_limits" split_words:"true"`
// `otp_limits` controls rate limits for OTP login attempts.
OTPLimits RateLimits `yaml:"otp_limits" json:"otp_limits,omitempty" koanf:"otp_limits" split_words:"true"`
// `password_limits` controls rate limits for password login operations.
PasswordLimits RateLimits `yaml:"password_limits" json:"password_limits,omitempty" koanf:"password_limits" split_words:"true"`
// `token_limits` controls rate limits for token exchange operations.
Expand Down
60 changes: 19 additions & 41 deletions backend/crypto/jwk/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,72 +5,50 @@ import (
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/teamhanko/hanko/backend/persistence/models"
"github.com/stretchr/testify/suite"
"github.com/teamhanko/hanko/backend/test"
"testing"
)

type mockJwkPersister struct {
jwks []models.Jwk
func TestJWKManagerSuite(t *testing.T) {
s := new(jwkManagerSuite)
suite.Run(t, s)
}

func (m *mockJwkPersister) Get(i int) (*models.Jwk, error) {
for _, v := range m.jwks {
if v.ID == i {
return &v, nil
}
}
return nil, nil
type jwkManagerSuite struct {
test.Suite
}

func (m *mockJwkPersister) GetAll() ([]models.Jwk, error) {
return m.jwks, nil
}

func (m *mockJwkPersister) GetLast() (*models.Jwk, error) {
index := len(m.jwks)
return &m.jwks[index-1], nil
}

func (m *mockJwkPersister) Create(jwk models.Jwk) error {
//increment id
index := len(m.jwks)
jwk.ID = index

m.jwks = append(m.jwks, jwk)
return nil
}

func TestDefaultManager(t *testing.T) {
func (s *jwkManagerSuite) TestDefaultManager() {
keys := []string{"asfnoadnfoaegnq3094intoaegjnoadjgnoadng", "apdisfoaiegnoaiegnbouaebgn982"}
//persister := mockJwkPersister{jwks: []models.Jwk{}}
persister := test.NewJwkPersister(nil)

persister := s.Storage.GetJwkPersister()

dm, err := NewDefaultManager(keys, persister)
require.NoError(t, err)
require.NoError(s.T(), err)
all, err := persister.GetAll()

require.NoError(t, err)
assert.Equal(t, 2, len(all))
require.NoError(s.T(), err)
assert.Equal(s.T(), 2, len(all))

js, err := dm.GetPublicKeys()
require.NoError(t, err)
assert.Equal(t, 2, js.Len())
require.NoError(s.T(), err)
assert.Equal(s.T(), 2, js.Len())

sk, err := dm.GetSigningKey()
require.NoError(t, err)
require.NoError(s.T(), err)

token := jwt.New()
token.Set("Payload", "isJustFine")
signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, sk))
require.NoError(t, err)
require.NoError(s.T(), err)

// Get Public Key of signing key
pk, err := sk.PublicKey()
require.NoError(t, err)
require.NoError(s.T(), err)

// Parse and Verify
tokenParsed, err := jwt.Parse(signed, jwt.WithKey(jwa.RS256, pk))
assert.NoError(t, err)
assert.Equal(t, token, tokenParsed)
assert.NoError(s.T(), err)
assert.Equal(s.T(), token, tokenParsed)
}
3 changes: 2 additions & 1 deletion backend/dto/intern/WebauthnCredential.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
"time"
)

func WebauthnCredentialToModel(credential *webauthn.Credential, userId uuid.UUID, backupEligible bool, backupState bool, authenticatorMetadata mapper.AuthenticatorMetadata) *models.WebauthnCredential {
func WebauthnCredentialToModel(credential *webauthn.Credential, userId uuid.UUID, backupEligible, backupState, mfaOnly bool, authenticatorMetadata mapper.AuthenticatorMetadata) *models.WebauthnCredential {
now := time.Now().UTC()
aaguid, _ := uuid.FromBytes(credential.Authenticator.AAGUID)
credentialID := base64.RawURLEncoding.EncodeToString(credential.ID)
Expand All @@ -28,6 +28,7 @@ func WebauthnCredentialToModel(credential *webauthn.Credential, userId uuid.UUID
UpdatedAt: now,
BackupEligible: backupEligible,
BackupState: backupState,
MFAOnly: mfaOnly,
}

for _, name := range credential.Transport {
Expand Down
49 changes: 34 additions & 15 deletions backend/dto/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,37 @@ package dto

import (
"github.com/gofrs/uuid"
"github.com/teamhanko/hanko/backend/config"
"github.com/teamhanko/hanko/backend/persistence/models"
"time"
)

type MFAConfig struct {
AuthAppSetUp bool `json:"auth_app_set_up"`
TOTPEnabled bool `json:"totp_enabled"`
SecurityKeysEnabled bool `json:"security_keys_enabled"`
}

type ProfileData struct {
UserID uuid.UUID `json:"user_id"`
WebauthnCredentials []WebauthnCredentialResponse `json:"passkeys,omitempty"`
Emails []EmailResponse `json:"emails,omitempty"`
Username *Username `json:"username,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
UserID uuid.UUID `json:"user_id"`
Passkeys []WebauthnCredentialResponse `json:"passkeys,omitempty"`
SecurityKeys []WebauthnCredentialResponse `json:"security_keys,omitempty"`
MFAConfig MFAConfig `json:"mfa_config"`
Emails []EmailResponse `json:"emails,omitempty"`
Username *Username `json:"username,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

func ProfileDataFromUserModel(user *models.User) *ProfileData {
var webauthnCredentials []WebauthnCredentialResponse
func ProfileDataFromUserModel(user *models.User, cfg *config.Config) *ProfileData {
var webauthnCredentials, securityKeys []WebauthnCredentialResponse
for _, webauthnCredentialModel := range user.WebauthnCredentials {
webauthnCredential := FromWebauthnCredentialModel(&webauthnCredentialModel)
webauthnCredentials = append(webauthnCredentials, *webauthnCredential)
if cfg.MFA.SecurityKeys.Enabled && webauthnCredentialModel.MFAOnly {
securityKeys = append(securityKeys, *webauthnCredential)
} else if cfg.Passkey.Enabled && !webauthnCredentialModel.MFAOnly {
webauthnCredentials = append(webauthnCredentials, *webauthnCredential)
}
}

var emails []EmailResponse
Expand All @@ -29,11 +42,17 @@ func ProfileDataFromUserModel(user *models.User) *ProfileData {
}

return &ProfileData{
UserID: user.ID,
WebauthnCredentials: webauthnCredentials,
Emails: emails,
Username: FromUsernameModel(user.Username),
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
UserID: user.ID,
Passkeys: webauthnCredentials,
SecurityKeys: securityKeys,
MFAConfig: MFAConfig{
AuthAppSetUp: user.OTPSecret != nil,
TOTPEnabled: cfg.MFA.Enabled && cfg.MFA.TOTP.Enabled,
SecurityKeysEnabled: cfg.MFA.Enabled && cfg.MFA.SecurityKeys.Enabled,
},
Emails: emails,
Username: FromUsernameModel(user.Username),
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}
}
Loading

0 comments on commit bc04b72

Please sign in to comment.