Skip to content

Commit

Permalink
feat: pin code login strategy
Browse files Browse the repository at this point in the history
  • Loading branch information
splaunov committed Jun 11, 2024
1 parent 1a70648 commit 302c013
Show file tree
Hide file tree
Showing 41 changed files with 914 additions and 26 deletions.
3 changes: 3 additions & 0 deletions cmd/clidoc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,9 @@ func init() {
"NewErrorValidationAddressUnknown": text.NewErrorValidationAddressUnknown(),
"NewInfoSelfServiceLoginCodeMFA": text.NewInfoSelfServiceLoginCodeMFA(),
"NewInfoSelfServiceLoginCodeMFAHint": text.NewInfoSelfServiceLoginCodeMFAHint("{maskedIdentifier}"),
"NewErrorValidationInvalidPin": text.NewErrorValidationInvalidPin(),
"NewInfoLoginPinLabel": text.NewInfoLoginPinLabel(),
"NewInfoNodeInputPin": text.NewInfoNodeInputPin(),
}
}

Expand Down
2 changes: 2 additions & 0 deletions driver/registry_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package driver
import (
"context"
"crypto/sha256"
"github.com/ory/kratos/selfservice/strategy/pin"
"net/http"
"strings"
"sync"
Expand Down Expand Up @@ -324,6 +325,7 @@ func (m *RegistryDefault) selfServiceStrategies() []any {
passkey.NewStrategy(m),
webauthn.NewStrategy(m),
lookup.NewStrategy(m),
pin.NewStrategy(m),
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions driver/registry_default_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -886,7 +886,7 @@ func TestDefaultRegistry_AllStrategies(t *testing.T) {
_, reg := internal.NewVeryFastRegistryWithoutDB(t)

t.Run("case=all login strategies", func(t *testing.T) {
expects := []string{"password", "oidc", "code", "totp", "passkey", "webauthn", "lookup_secret"}
expects := []string{"password", "oidc", "code", "totp", "passkey", "webauthn", "lookup_secret", "pin"}
s := reg.AllLoginStrategies()
require.Len(t, s, len(expects))
for k, e := range expects {
Expand All @@ -904,7 +904,7 @@ func TestDefaultRegistry_AllStrategies(t *testing.T) {
})

t.Run("case=all settings strategies", func(t *testing.T) {
expects := []string{"password", "oidc", "profile", "totp", "passkey", "webauthn", "lookup_secret"}
expects := []string{"password", "oidc", "profile", "totp", "passkey", "webauthn", "lookup_secret", "pin"}
s := reg.AllSettingsStrategies()
require.Len(t, s, len(expects))
for k, e := range expects {
Expand Down
11 changes: 11 additions & 0 deletions embedx/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1828,6 +1828,17 @@
}
}
}
},
"pin": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"title": "Enables Pin Method",
"default": false
}
}
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions identity/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ const (
CredentialsTypeCodeAuth CredentialsType = "code"
CredentialsTypePasskey CredentialsType = "passkey"
CredentialsTypeProfile CredentialsType = "profile"
CredentialsTypePin CredentialsType = "pin"
)

func (c CredentialsType) String() string {
Expand Down Expand Up @@ -123,6 +124,7 @@ var AllCredentialTypes = []CredentialsType{
CredentialsTypeWebAuthn,
CredentialsTypeCodeAuth,
CredentialsTypePasskey,
CredentialsTypePin,
}

const (
Expand Down
9 changes: 9 additions & 0 deletions identity/credentials_pin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package identity

// CredentialsPin contains the configuration for credentials of the pin type.
//
// swagger:model identityCredentialsPin
type CredentialsPin struct {
// HashedPin is a hash-representation of the pin.
HashedPin string `json:"hashed_pin"`
}
1 change: 1 addition & 0 deletions internal/client-go/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e h1:bRhVy7zSSasaqNksaRZiA5EEI+Ei4I1nO5Jh72wfHlg=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
Expand Down
5 changes: 3 additions & 2 deletions internal/testhelpers/selfservice_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ func SubmitLoginForm(
forced bool,
expectedStatusCode int,
expectedURL string,
opts ...InitFlowWithOption,
) string {
if hc == nil {
hc = new(http.Client)
Expand All @@ -239,9 +240,9 @@ func SubmitLoginForm(
hc.Transport = NewTransportWithLogger(hc.Transport, t)
var f *kratos.LoginFlow
if isAPI {
f = InitializeLoginFlowViaAPI(t, hc, publicTS, forced)
f = InitializeLoginFlowViaAPI(t, hc, publicTS, forced, opts...)
} else {
f = InitializeLoginFlowViaBrowser(t, hc, publicTS, forced, isSPA, false, false)
f = InitializeLoginFlowViaBrowser(t, hc, publicTS, forced, isSPA, false, false, opts...)
}

time.Sleep(time.Millisecond) // add a bit of delay to allow `1ns` to time out.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DELETE FROM identity_credential_types WHERE name = 'pin'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
INSERT INTO identity_credential_types (id, name) SELECT 'b2f010e4-0126-4db2-b24d-8ea390d1e25f', 'pin' WHERE NOT EXISTS ( SELECT * FROM identity_credential_types WHERE name = 'pin')
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DELETE FROM identity_credential_types WHERE name = 'pin'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
INSERT INTO identity_credential_types (id, name) SELECT 'b2f010e4-0126-4db2-b24d-8ea390d1e25f', 'pin' WHERE NOT EXISTS ( SELECT * FROM identity_credential_types WHERE name = 'pin')
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DELETE FROM identity_credential_types WHERE name = 'pin'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
INSERT INTO identity_credential_types (id, name) SELECT 'b2f010e4-0126-4db2-b24d-8ea390d1e25f', 'pin' WHERE NOT EXISTS ( SELECT * FROM identity_credential_types WHERE name = 'pin')
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DELETE FROM identity_credential_types WHERE name = 'pin'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
INSERT INTO identity_credential_types (id, name) SELECT 'b2f010e4-0126-4db2-b24d-8ea390d1e25f', 'pin' WHERE NOT EXISTS ( SELECT * FROM identity_credential_types WHERE name = 'pin')
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DELETE FROM identity_credential_types WHERE name = 'pin'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
INSERT INTO identity_credential_types (id, name) SELECT 'b2f010e4-0126-4db2-b24d-8ea390d1e25f', 'pin' WHERE NOT EXISTS ( SELECT * FROM identity_credential_types WHERE name = 'pin')
2 changes: 1 addition & 1 deletion persistence/sql/persister_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ func TestPersister(t *testing.T) {
})

t.Run("case=credentials types", func(t *testing.T) {
for _, ct := range []ri.CredentialsType{ri.CredentialsTypeOIDC, ri.CredentialsTypePassword} {
for _, ct := range []ri.CredentialsType{ri.CredentialsTypeOIDC, ri.CredentialsTypePassword, ri.CredentialsTypePin} {
require.NoError(t, p.(*sql.Persister).Connection(context.Background()).Where("name = ?", ct).First(&ri.CredentialsTypeTable{}))
}
})
Expand Down
10 changes: 10 additions & 0 deletions schema/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -370,3 +370,13 @@ func NewUnknownAddressError() error {
},
)
}

func NewInvalidPinError() error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: `the provided pin code is invalid`,
InstancePtr: "#/",
},
Messages: new(text.Messages).Add(text.NewErrorValidationInvalidPin()),
})
}
5 changes: 4 additions & 1 deletion selfservice/flow/login/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ var (
ErrSessionHasAALAlready = herodot.ErrUnauthorized.WithID(text.ErrIDSessionHasAALAlready).WithError("session has the requested authenticator assurance level already").WithReason("The session has the requested AAL already.")

// ErrSessionRequiredForHigherAAL is returned when someone requests AAL2 or AAL3 even though no active session exists yet.
ErrSessionRequiredForHigherAAL = herodot.ErrUnauthorized.WithID(text.ErrIDSessionRequiredForHigherAAL).WithError("aal2 and aal3 can only be requested if a session exists already").WithReason("You can not requested a higher AAL (AAL2/AAL3) without an active session.")
ErrSessionRequiredForHigherAAL = herodot.ErrUnauthorized.WithID(text.ErrIDSessionRequired).WithError("aal2 and aal3 can only be requested if a session exists already").WithReason("You can not requested a higher AAL (AAL2/AAL3) without an active session.")

// ErrSessionRequiredForAAL0 is returned when someone requests AAL2 or AAL3 even though no active session exists yet.
ErrSessionRequiredForAAL0 = herodot.ErrUnauthorized.WithID(text.ErrIDSessionRequired).WithError("aal0 can only be requested if a session exists already").WithReason("You can not request AAL0 without an active session.")
)

type (
Expand Down
17 changes: 17 additions & 0 deletions selfservice/flow/login/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ func (h *Handler) NewLoginFlow(w http.ResponseWriter, r *http.Request, ft flow.T
}

switch cs := stringsx.SwitchExact(string(f.RequestedAAL)); {
case cs.AddCase(string(identity.NoAuthenticatorAssuranceLevel)):
f.RequestedAAL = identity.NoAuthenticatorAssuranceLevel
case cs.AddCase(string(identity.AuthenticatorAssuranceLevel1)):
f.RequestedAAL = identity.AuthenticatorAssuranceLevel1
case cs.AddCase(string(identity.AuthenticatorAssuranceLevel2)):
Expand Down Expand Up @@ -157,6 +159,11 @@ func (h *Handler) NewLoginFlow(w http.ResponseWriter, r *http.Request, ft flow.T
return nil, nil, errors.WithStack(ErrSessionRequiredForHigherAAL)
}

// We can not request AAL0 because we must get a session first.
if f.RequestedAAL == identity.NoAuthenticatorAssuranceLevel {
return nil, nil, errors.WithStack(ErrSessionRequiredForAAL0)
}

// We are setting refresh to false if no session exists.
f.Refresh = false

Expand All @@ -173,6 +180,11 @@ func (h *Handler) NewLoginFlow(w http.ResponseWriter, r *http.Request, ft flow.T

// We are not refreshing - so are we requesting MFA?

if f.RequestedAAL == identity.NoAuthenticatorAssuranceLevel {
// We are not requesting MFA but additional authentication which doesn't change session's AAL, like a pin code check
goto preLoginHook
}

// If level is 1 we are not requesting AAL -> we are logged in already.
if f.RequestedAAL == identity.AuthenticatorAssuranceLevel1 {
return nil, sess, errors.WithStack(ErrAlreadyLoggedIn)
Expand Down Expand Up @@ -758,6 +770,11 @@ func (h *Handler) updateLoginFlow(w http.ResponseWriter, r *http.Request, _ http
goto continueLogin
}

if f.RequestedAAL == identity.NoAuthenticatorAssuranceLevel {
// If we want to run through additional authentication with pin code, continue the login
goto continueLogin
}

if f.RequestedAAL > sess.AuthenticatorAssuranceLevel {
// If we want to upgrade AAL, continue the login
goto continueLogin
Expand Down
24 changes: 12 additions & 12 deletions selfservice/flow/login/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,12 @@ func TestFlowLifecycle(t *testing.T) {
assertx.EqualAsJSON(t, "Please complete the second authentication challenge.", gjson.GetBytes(body, "ui.messages.1.text").String(), "%s", body)
})

t.Run("case=can not request aal0 on unauthenticated request", func(t *testing.T) {
res, body := initFlow(t, url.Values{"aal": {"aal0"}}, true)
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
assertx.EqualAsJSON(t, "You can not request AAL0 without an active session.", gjson.GetBytes(body, "error.reason").String())
})

t.Run("case=can not request aal2 on unauthenticated request", func(t *testing.T) {
res, body := initFlow(t, url.Values{"aal": {"aal2"}}, true)
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
Expand All @@ -588,12 +594,6 @@ func TestFlowLifecycle(t *testing.T) {
assertx.EqualAsJSON(t, "A valid session was detected and thus login is not possible. Did you forget to set `?refresh=true`?", gjson.GetBytes(body, "error.reason").String())
})

t.Run("case=aal0 is not a valid value", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, url.Values{"aal": {"aal0"}}, true)
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
assertx.EqualAsJSON(t, "Unable to parse AuthenticationMethod Assurance Level (AAL): expected one of [aal1, aal2] but got aal0", gjson.GetBytes(body, "error.reason").String())
})

t.Run("case=indicates two factor auth", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, url.Values{"aal": {"aal2"}}, true)
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
Expand Down Expand Up @@ -663,6 +663,12 @@ func TestFlowLifecycle(t *testing.T) {
assert.Contains(t, res.Request.URL.String(), "https://www.ory.sh")
})

t.Run("case=can not request aal0 on unauthenticated request", func(t *testing.T) {
res, body := initFlow(t, url.Values{"aal": {"aal0"}}, false)
assert.Contains(t, res.Request.URL.String(), errorTS.URL)
assertx.EqualAsJSON(t, "You can not request AAL0 without an active session.", gjson.GetBytes(body, "reason").String())
})

t.Run("case=can not request aal2 on unauthenticated request", func(t *testing.T) {
res, body := initFlow(t, url.Values{"aal": {"aal2"}}, false)
assert.Contains(t, res.Request.URL.String(), errorTS.URL)
Expand All @@ -674,12 +680,6 @@ func TestFlowLifecycle(t *testing.T) {
assert.Contains(t, res.Request.URL.String(), "https://www.ory.sh")
})

t.Run("case=aal0 is not a valid value", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, url.Values{"aal": {"aal0"}}, false)
assert.Contains(t, res.Request.URL.String(), errorTS.URL)
assertx.EqualAsJSON(t, "Unable to parse AuthenticationMethod Assurance Level (AAL): expected one of [aal1, aal2] but got aal0", gjson.GetBytes(body, "reason").String())
})

t.Run("case=indicates two factor auth", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, url.Values{"aal": {"aal2"}}, false)
assert.Contains(t, res.Request.URL.String(), loginTS.URL)
Expand Down
36 changes: 36 additions & 0 deletions selfservice/strategy/pin/.schema/login.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"$id": "https://schemas.ory.sh/kratos/selfservice/strategy/pin/login.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"csrf_token": {
"type": "string"
},
"pin": {
"type": "string",
"minLength": 4
},
"method": {
"type": "string"
}
},
"allOf": [
{
"if": {
"properties": {
"method": {
"const": "pin"
}
},
"required": [
"method"
]
},
"then": {
"required": [
"pin"
]
}
}
]
}
20 changes: 20 additions & 0 deletions selfservice/strategy/pin/.schema/settings.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"$id": "https://schemas.ory.sh/kratos/selfservice/strategy/pin/settings.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": [
"pin"
],
"properties": {
"csrf_token": {
"type": "string"
},
"method": {
"type": "string"
},
"pin": {
"type": "string",
"minLength": 4
}
}
}
Loading

0 comments on commit 302c013

Please sign in to comment.