Skip to content

Commit

Permalink
refactor(session): aal computation
Browse files Browse the repository at this point in the history
  • Loading branch information
aeneasr committed Mar 7, 2022
1 parent c7eb970 commit a136de9
Show file tree
Hide file tree
Showing 36 changed files with 435 additions and 260 deletions.
9 changes: 0 additions & 9 deletions driver/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -1238,12 +1238,3 @@ func (p *Config) getTSLCertificates(daemon, certBase64, keyBase64, certPath, key
p.l.Infof("TLS has not been configured for %s, skipping", daemon)
return nil
}

func (p *Config) PasswordlessMethods() []string {
if p.WebAuthnForPasswordless() {
return []string{
"webauthn",
}
}
return nil
}
2 changes: 0 additions & 2 deletions driver/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1033,10 +1033,8 @@ func TestPasswordless(t *testing.T) {
configx.WithValue(config.ViperKeyWebAuthnPasswordless, true))
require.NoError(t, err)

assert.Equal(t, []string{"webauthn"}, conf.PasswordlessMethods())
assert.True(t, conf.WebAuthnForPasswordless())
conf.MustSet(config.ViperKeyWebAuthnPasswordless, false)
assert.Empty(t, conf.PasswordlessMethods())
assert.False(t, conf.WebAuthnForPasswordless())
}

Expand Down
76 changes: 50 additions & 26 deletions identity/aal.go
Original file line number Diff line number Diff line change
@@ -1,37 +1,61 @@
package identity

func DetermineAAL(cts []CredentialsType, passwordless []string) AuthenticatorAssuranceLevel {
import "github.com/tidwall/gjson"

func IsCredentialAAL1(credential Credentials, webAuthnIsPasswordless bool) bool {
switch credential.Type {
case CredentialsTypeRecoveryLink:
fallthrough
case CredentialsTypeOIDC:
fallthrough
case "v0.6_legacy_session":
fallthrough
case CredentialsTypePassword:
return true
case CredentialsTypeWebAuthn:
for _, c := range gjson.GetBytes(credential.Config, "credentials").Array() {
if c.Get("is_passwordless").Bool() {
return webAuthnIsPasswordless
}
}
return false
}
return false
}

func IsCredentialAAL2(credential Credentials, webAuthnIsPasswordless bool) bool {
switch credential.Type {
case CredentialsTypeTOTP:
return true
case CredentialsTypeLookup:
return true
case CredentialsTypeWebAuthn:
creds := gjson.GetBytes(credential.Config, "credentials").Array()
if len(creds) == 0 {
// Legacy credential before passwordless -> AAL2
return true
}
for _, c := range gjson.GetBytes(credential.Config, "credentials").Array() {
if !c.Get("is_passwordless").Bool() {
return !webAuthnIsPasswordless
}
}
}
return false
}

func MaximumAAL(c map[CredentialsType]Credentials, conf interface {
WebAuthnForPasswordless() bool
}) AuthenticatorAssuranceLevel {
aal := NoAuthenticatorAssuranceLevel

var firstFactor bool
var secondFactor bool
for _, a := range cts {
switch a {
case CredentialsTypeRecoveryLink:
fallthrough
case CredentialsTypeOIDC:
fallthrough
case "v0.6_legacy_session":
fallthrough
case CredentialsTypePassword:
for _, a := range c {
if IsCredentialAAL1(a, conf.WebAuthnForPasswordless()) {
firstFactor = true
case CredentialsTypeTOTP:
} else if IsCredentialAAL2(a, conf.WebAuthnForPasswordless()) {
secondFactor = true
case CredentialsTypeLookup:
secondFactor = true
case CredentialsTypeWebAuthn:
var wasFirstFactor bool
for _, pl := range passwordless {
if pl == CredentialsTypeWebAuthn.String() {
firstFactor = true
wasFirstFactor = true
break
}
}

if !wasFirstFactor {
secondFactor = true
}
}
}

Expand Down
150 changes: 102 additions & 48 deletions identity/aal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,135 +6,189 @@ import (
"github.com/stretchr/testify/assert"
)

func TestDetermineAAL(t *testing.T) {
type fakeConfig struct {
less bool
}

func (f *fakeConfig) WebAuthnForPasswordless() bool {
return f.less
}

func TestDetermineAAL(t *testing.T) {
for _, tc := range []struct {
d string
methods []CredentialsType
passwordless []string
expected AuthenticatorAssuranceLevel
d string
methods map[CredentialsType]Credentials
expected AuthenticatorAssuranceLevel
webAuthnPasswordless bool
}{
{
d: "no amr means no assurance",
expected: NoAuthenticatorAssuranceLevel,
},
{
d: "password is aal1",
methods: []CredentialsType{CredentialsTypePassword},
methods: map[CredentialsType]Credentials{CredentialsTypePassword: {Type: CredentialsTypePassword}},
expected: AuthenticatorAssuranceLevel1,
},
{
d: "oidc is aal1",
methods: []CredentialsType{CredentialsTypeOIDC},
methods: map[CredentialsType]Credentials{CredentialsTypeOIDC: {Type: CredentialsTypeOIDC}},
expected: AuthenticatorAssuranceLevel1,
},
{
d: "recovery is aal1",
methods: []CredentialsType{CredentialsTypeRecoveryLink},
methods: map[CredentialsType]Credentials{CredentialsTypeRecoveryLink: {Type: CredentialsTypeRecoveryLink}},
expected: AuthenticatorAssuranceLevel1,
},
{
d: "legacy is aal1",
methods: []CredentialsType{"v0.6_legacy_session"},
methods: map[CredentialsType]Credentials{"v0.6_legacy_session": {Type: "v0.6_legacy_session"}},
expected: AuthenticatorAssuranceLevel1,
},
{
d: "mix of password, oidc, recovery is still aal1",
methods: []CredentialsType{
CredentialsTypeRecoveryLink, CredentialsTypeOIDC, CredentialsTypePassword,
methods: map[CredentialsType]Credentials{
CredentialsTypeRecoveryLink: {Type: CredentialsTypeRecoveryLink},
CredentialsTypeOIDC: {Type: CredentialsTypeOIDC},
CredentialsTypePassword: {Type: CredentialsTypePassword},
},
expected: AuthenticatorAssuranceLevel1,
},
{
d: "just totp is aal0",
methods: []CredentialsType{
CredentialsTypeTOTP,
},
d: "just totp is aal0",
methods: map[CredentialsType]Credentials{CredentialsTypeTOTP: {Type: CredentialsTypeTOTP}},
expected: NoAuthenticatorAssuranceLevel,
},
{
d: "password + totp is aal2",
methods: []CredentialsType{
CredentialsTypePassword,
CredentialsTypeTOTP,
methods: map[CredentialsType]Credentials{
CredentialsTypeTOTP: {Type: CredentialsTypeTOTP},
CredentialsTypePassword: {Type: CredentialsTypePassword},
},
expected: AuthenticatorAssuranceLevel2,
},
{
d: "password + lookup is aal2",
methods: []CredentialsType{
CredentialsTypePassword,
CredentialsTypeLookup,
methods: map[CredentialsType]Credentials{
CredentialsTypeLookup: {Type: CredentialsTypeLookup},
CredentialsTypePassword: {Type: CredentialsTypePassword},
},
expected: AuthenticatorAssuranceLevel2,
},
{
d: "password + webauthn is aal2",
methods: []CredentialsType{
CredentialsTypePassword,
CredentialsTypeWebAuthn,
methods: map[CredentialsType]Credentials{
CredentialsTypeWebAuthn: {Type: CredentialsTypeWebAuthn},
CredentialsTypePassword: {Type: CredentialsTypePassword},
},
expected: AuthenticatorAssuranceLevel2,
},
{
d: "oidc + totp is aal2",
methods: []CredentialsType{
CredentialsTypeOIDC,
CredentialsTypeTOTP,
methods: map[CredentialsType]Credentials{
CredentialsTypeOIDC: {Type: CredentialsTypeOIDC},
CredentialsTypeTOTP: {Type: CredentialsTypeTOTP},
},
expected: AuthenticatorAssuranceLevel2,
},
{
d: "oidc + lookup is aal2",
methods: []CredentialsType{
CredentialsTypeOIDC,
CredentialsTypeLookup,
methods: map[CredentialsType]Credentials{
CredentialsTypeOIDC: {Type: CredentialsTypeOIDC},
CredentialsTypeLookup: {Type: CredentialsTypeLookup},
},
expected: AuthenticatorAssuranceLevel2,
},
{
d: "recovery link + totp is aal2",
methods: []CredentialsType{
CredentialsTypeRecoveryLink,
CredentialsTypeTOTP,
methods: map[CredentialsType]Credentials{
CredentialsTypeRecoveryLink: {Type: CredentialsTypeRecoveryLink},
CredentialsTypeTOTP: {Type: CredentialsTypeTOTP},
},
expected: AuthenticatorAssuranceLevel2,
},
{
d: "recovery link + lookup is aal2",
methods: []CredentialsType{
CredentialsTypeRecoveryLink,
CredentialsTypeLookup,
methods: map[CredentialsType]Credentials{
CredentialsTypeRecoveryLink: {Type: CredentialsTypeRecoveryLink},
CredentialsTypeLookup: {Type: CredentialsTypeLookup},
},
expected: AuthenticatorAssuranceLevel2,
},
{
d: "webauthn only is aal1 if passwordless is set",
methods: []CredentialsType{
CredentialsTypeWebAuthn,
methods: map[CredentialsType]Credentials{
CredentialsTypeWebAuthn: {Type: CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{"is_passwordless":true}]}`)},
},
passwordless: []string{CredentialsTypeWebAuthn.String()},
expected: AuthenticatorAssuranceLevel1,
expected: AuthenticatorAssuranceLevel1,
webAuthnPasswordless: true,
},
{
d: "webauthn and another method is aal1 if passwordless is set",
methods: []CredentialsType{
CredentialsTypePassword,
CredentialsTypeWebAuthn,
methods: map[CredentialsType]Credentials{
CredentialsTypePassword: {Type: CredentialsTypePassword},
CredentialsTypeWebAuthn: {Type: CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{"is_passwordless":true}]}`)},
},
passwordless: []string{CredentialsTypeWebAuthn.String()},
expected: AuthenticatorAssuranceLevel1,
expected: AuthenticatorAssuranceLevel1,
},
{
d: "webauthn only is unknown if passwordless is not set",
methods: []CredentialsType{
CredentialsTypeWebAuthn,
methods: map[CredentialsType]Credentials{
CredentialsTypeWebAuthn: {Type: CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{"is_passwordless":false}]}`)},
},
expected: NoAuthenticatorAssuranceLevel,
},
{
d: "webauthn with password is aal1 if passwordless is set",
methods: map[CredentialsType]Credentials{
CredentialsTypePassword: {Type: CredentialsTypePassword},
CredentialsTypeWebAuthn: {Type: CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{"is_passwordless":true}]}`)},
},
expected: AuthenticatorAssuranceLevel1,
},
{
d: "webauthn with password and two credentials is aal2 if no passwordless",
methods: map[CredentialsType]Credentials{
CredentialsTypePassword: {Type: CredentialsTypePassword},
CredentialsTypeWebAuthn: {Type: CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{"is_passwordless":false},{"is_passwordless":true}]}`)},
},
expected: AuthenticatorAssuranceLevel2,
},
{
d: "webauthn with password and two credentials is aal1 if passwordless",
methods: map[CredentialsType]Credentials{
CredentialsTypePassword: {Type: CredentialsTypePassword},
CredentialsTypeWebAuthn: {Type: CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{"is_passwordless":false},{"is_passwordless":true}]}`)},
},
expected: AuthenticatorAssuranceLevel1,
webAuthnPasswordless: true,
},
{
d: "webauthn with password and two credentials without passwordless key is aal2",
methods: map[CredentialsType]Credentials{
CredentialsTypePassword: {Type: CredentialsTypePassword},
CredentialsTypeWebAuthn: {Type: CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{},{}]}`)},
},
expected: AuthenticatorAssuranceLevel2,
},
{
d: "webauthn with two credentials is still aal1 if passwordless",
methods: map[CredentialsType]Credentials{
CredentialsTypeWebAuthn: {Type: CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{"is_passwordless":false},{"is_passwordless":true}]}`)},
},
expected: AuthenticatorAssuranceLevel1,
webAuthnPasswordless: true,
},
{
d: "webauthn with two credentials is no aal",
methods: map[CredentialsType]Credentials{
CredentialsTypeWebAuthn: {Type: CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{"is_passwordless":false},{"is_passwordless":true}]}`)},
},
expected: NoAuthenticatorAssuranceLevel,
},
} {
t.Run("case="+tc.d, func(t *testing.T) {
assert.Equal(t, tc.expected, DetermineAAL(tc.methods, tc.passwordless))
assert.Equal(t, tc.expected, MaximumAAL(tc.methods, &fakeConfig{less: tc.webAuthnPasswordless}))
})
}
}
6 changes: 3 additions & 3 deletions internal/testhelpers/handler_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func MockSetSession(t *testing.T, reg mockDeps, conf *config.Config) httprouter.
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i))

activeSession, _ := session.NewActiveSession(i, conf, time.Now().UTC(), identity.CredentialsTypePassword, reg.Config(r.Context()).PasswordlessMethods())
activeSession, _ := session.NewActiveSession(i, conf, time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
if aal := r.URL.Query().Get("set_aal"); len(aal) > 0 {
activeSession.AuthenticatorAssuranceLevel = identity.AuthenticatorAssuranceLevel(aal)
}
Expand Down Expand Up @@ -118,9 +118,9 @@ func MockSessionCreateHandlerWithIdentityAndAMR(t *testing.T, reg mockDeps, i *i
sess.ExpiresAt = time.Now().UTC().Add(time.Hour * 24)
sess.Active = true
for _, method := range methods {
sess.CompletedLoginFor(method)
sess.CompletedLoginFor(method, "")
}
sess.SetAuthenticatorAssuranceLevel(nil)
sess.SetAuthenticatorAssuranceLevel()

if _, err := reg.Config(context.Background()).DefaultIdentityTraitsSchemaURL(); err != nil {
SetDefaultIdentitySchema(reg.Config(context.Background()), "file://./stub/fake-session.schema.json")
Expand Down
2 changes: 1 addition & 1 deletion internal/testhelpers/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ func CreateSession(t *testing.T, reg driver.Registry) *session.Session {
ctx := context.Background()
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, i))
sess, err := session.NewActiveSession(i, reg.Config(ctx), time.Now().UTC(), identity.CredentialsTypePassword, reg.Config(context.Background()).PasswordlessMethods())
sess, err := session.NewActiveSession(i, reg.Config(ctx), time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
require.NoError(t, err)
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
return sess
Expand Down
Loading

0 comments on commit a136de9

Please sign in to comment.