Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Passwordless SMS code authentication #2033

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .schema/openapi/patches/selfservice.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@
path: /components/schemas/verificationFlowState/enum
value:
- choose_method
- sent_email
- sent
- passed_challenge
# End

Expand Down
5 changes: 5 additions & 0 deletions cmd/clidoc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,11 @@ func init() {
"NewErrorValidationAddressUnknown": text.NewErrorValidationAddressUnknown(),
"NewInfoSelfServiceLoginCodeMFA": text.NewInfoSelfServiceLoginCodeMFA(),
"NewInfoSelfServiceLoginCodeMFAHint": text.NewInfoSelfServiceLoginCodeMFAHint("{maskedIdentifier}"),
"NewErrorValidationInvalidCode": text.NewErrorValidationInvalidCode(),
"NewErrorCodeSent": text.NewErrorCodeSent(),
"NewErrorValidationSMSSpam": text.NewErrorValidationSMSSpam(),
"NewInfoNodeInputPhone": text.NewInfoNodeInputPhone(),
"NewInfoSelfServicePhoneVerificationSuccessful": text.NewInfoSelfServicePhoneVerificationSuccessful(),
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
code {{ .Code }}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Your registration code is: {{ .RegistrationCode }}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
stub sms body {{ .Body }}
54 changes: 54 additions & 0 deletions courier/template/sms/registration_code_valid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package sms

import (
"context"
"encoding/json"
"os"

"github.com/ory/kratos/courier/template"
)

type (
RegistrationCodeValid struct {
deps template.Dependencies
model *RegistrationCodeValidModel
}
RegistrationCodeValidModel struct {
To string `json:"to"`
Traits map[string]interface{} `json:"traits"`
RegistrationCode string `json:"registration_code"`
RequestURL string `json:"request_url"`
TransientPayload map[string]interface{} `json:"transient_payload"`
}
)

func NewRegistrationCodeValid(d template.Dependencies, m *RegistrationCodeValidModel) *RegistrationCodeValid {
return &RegistrationCodeValid{deps: d, model: m}
}

func (t *RegistrationCodeValid) PhoneNumber() (string, error) {
return t.model.To, nil
}

func (t *RegistrationCodeValid) SMSBody(ctx context.Context) (string, error) {
return template.LoadText(
ctx,
t.deps,
os.DirFS(t.deps.CourierConfig().CourierTemplatesRoot(ctx)),
"registration_code/valid/sms.body.gotmpl",
"registration_code/valid/sms.body*",
t.model,
t.deps.CourierConfig().CourierSMSTemplatesLoginCodeValid(ctx).Body.PlainText,
)
}

func (t *RegistrationCodeValid) MarshalJSON() ([]byte, error) {
return json.Marshal(t.model)
}

func (t *RegistrationCodeValid) TemplateType() template.TemplateType {
return template.TypeRegistrationCodeValid
}
1 change: 1 addition & 0 deletions courier/template/sms/verification_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type (

VerificationCodeValidModel struct {
To string `json:"to"`
VerificationURL string `json:"verification_url"`
VerificationCode string `json:"verification_code"`
Identity map[string]interface{} `json:"identity"`
RequestURL string `json:"request_url"`
Expand Down
26 changes: 26 additions & 0 deletions driver/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@ const (
ViperKeyClientHTTPPrivateIPExceptionURLs = "clients.http.private_ip_exception_urls"
ViperKeyPreviewDefaultReadConsistencyLevel = "preview.default_read_consistency_level"
ViperKeyVersion = "version"
CodeTestNumbers = "selfservice.methods.code.config.test_numbers"
CodeSMSSpamProtectionEnabled = "selfservice.methods.code.config.sms_spam_protection.enabled"
CodeSMSSpamProtectionMaxSingleNumber = "selfservice.methods.code.config.sms_spam_protection.max_single_number"
CodeSMSSpamProtectionMaxNumbersRange = "selfservice.methods.code.config.sms_spam_protection.max_numbers_range"
ViperKeyCourierTemplatesLoginValidSMS = "courier.templates.login.valid.sms"
)

const (
Expand Down Expand Up @@ -317,6 +322,7 @@ type (
CourierWorkerPullCount(ctx context.Context) int
CourierWorkerPullWait(ctx context.Context) time.Duration
CourierChannels(context.Context) ([]*CourierChannel, error)
CourierTemplatesLoginValidSMS(ctx context.Context) string
}
)

Expand Down Expand Up @@ -1565,6 +1571,26 @@ func (p *Config) getTLSCertificates(ctx context.Context, daemon, certBase64, key
return nil
}

func (p *Config) SelfServiceCodeTestNumbers(ctx context.Context) []string {
return p.GetProvider(ctx).Strings(CodeTestNumbers)
}

func (p *Config) SelfServiceCodeSMSSpamProtectionEnabled() bool {
return p.p.Bool(CodeSMSSpamProtectionEnabled)
}

func (p *Config) SelfServiceCodeSMSSpamProtectionMaxSingleNumber() int {
return p.p.Int(CodeSMSSpamProtectionMaxSingleNumber)
}

func (p *Config) SelfServiceCodeSMSSpamProtectionMaxNumbersRange() int {
return p.p.Int(CodeSMSSpamProtectionMaxNumbersRange)
}

func (p *Config) CourierTemplatesLoginValidSMS(ctx context.Context) string {
return p.GetProvider(ctx).String(ViperKeyCourierTemplatesLoginValidSMS)
}

func (p *Config) GetProvider(ctx context.Context) *configx.Provider {
return p.c.Config(ctx, p.p)
}
Expand Down
30 changes: 30 additions & 0 deletions embedx/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1518,6 +1518,36 @@
"pattern": "^([0-9]+(ns|us|ms|s|m|h))+$",
"default": "1h",
"examples": ["1h", "1m", "1s"]
},
"test_numbers": {
"type": "array",
"description": "Phone numbers for test accounts",
"items": {
"type": "string"
}
},
"sms_spam_protection": {
"title": "SMS spam protection",
"description": "Blocks from sending too many messages to the same number or to a range of numbers",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"title": "Enables spam protection",
"default": false
},
"max_single_number": {
"type": "integer",
"title": "How many messages are allowed to be sent to a number per week",
"default": 50
},
"max_numbers_range": {
"type": "integer",
"title": "How many messages are allowed to be sent to a numbers range per week",
"default": 100
}
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/term v0.0.0-20220808134915-39b0c02b01ae // indirect
github.com/nyaruka/phonenumbers v1.1.6 // indirect
github.com/nyaruka/phonenumbers v1.1.6
github.com/ogier/pflag v0.0.1 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
Expand Down
1 change: 1 addition & 0 deletions identity/address.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ package identity

const (
AddressTypeEmail = "email"
AddressTypePhone = "sms"
)
43 changes: 36 additions & 7 deletions identity/extension_credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"strings"
"sync"

"github.com/nyaruka/phonenumbers"

"github.com/ory/jsonschema/v3"
"github.com/ory/x/sqlxx"
"github.com/ory/x/stringslice"
Expand All @@ -23,7 +25,7 @@ type SchemaExtensionCredentials struct {
}

func NewSchemaExtensionCredentials(i *Identity) *SchemaExtensionCredentials {
return &SchemaExtensionCredentials{i: i}
return &SchemaExtensionCredentials{i: i, v: make(map[CredentialsType][]string)}
}

func (r *SchemaExtensionCredentials) setIdentifier(ct CredentialsType, value interface{}) {
Expand Down Expand Up @@ -64,12 +66,14 @@ func (r *SchemaExtensionCredentials) Run(ctx jsonschema.ValidationContext, s sch
}

r.setIdentifier(CredentialsTypeCodeAuth, value)
// case f.AddCase(AddressTypePhone):
// if !jsonschema.Formats["tel"](value) {
// return ctx.Error("format", "%q is not a valid %q", value, s.Credentials.Code.Via)
// }

// r.setIdentifier(CredentialsTypeCodeAuth, value, CredentialsIdentifierAddressTypePhone)
case f.AddCase(AddressTypePhone):
phoneNumber, err := phonenumbers.Parse(fmt.Sprintf("%s", value), "")
if err != nil {
validationError := ctx.Error("format", "%s", err)
return validationError
}
e164 := fmt.Sprintf("+%d%d", *phoneNumber.CountryCode, *phoneNumber.NationalNumber)
r.setIdentifier(CredentialsTypeCodeAuth, e164)
default:
return ctx.Error("", "credentials.code.via has unknown value %q", s.Credentials.Code.Via)
}
Expand All @@ -79,5 +83,30 @@ func (r *SchemaExtensionCredentials) Run(ctx jsonschema.ValidationContext, s sch
}

func (r *SchemaExtensionCredentials) Finish() error {
r.l.Lock()
defer r.l.Unlock()

for ct := range r.i.Credentials {
_, ok := r.v[ct]
if !ok {
r.v[ct] = []string{}
}
}
for ct, identifiers := range r.v {
cred, ok := r.i.GetCredentials(ct)
if !ok {
cred = &Credentials{
Type: ct,
Identifiers: []string{},
Config: sqlxx.JSONRawMessage{},
}
}

if ct == CredentialsTypePassword || ct == CredentialsTypeCodeAuth {
cred.Identifiers = identifiers
r.i.SetCredentials(ct, *cred)
}
}

return nil
}
32 changes: 29 additions & 3 deletions identity/extension_credentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package identity_test
import (
"bytes"
"context"
"errors"
"fmt"
"testing"

Expand Down Expand Up @@ -36,6 +37,15 @@ func TestSchemaExtensionCredentials(t *testing.T) {
expect: []string{"[email protected]"},
ct: identity.CredentialsTypePassword,
},
{
doc: `{}`,
schema: "file://./stub/extension/credentials/schema.json",
expect: []string{},
existing: &identity.Credentials{
Identifiers: []string{"[email protected]"},
},
ct: identity.CredentialsTypePassword,
},
{
doc: `{"emails":["[email protected]","[email protected]","[email protected]"], "username": "foobar"}`,
schema: "file://./stub/extension/credentials/multi.schema.json",
Expand Down Expand Up @@ -87,6 +97,18 @@ func TestSchemaExtensionCredentials(t *testing.T) {
},
ct: identity.CredentialsTypeCodeAuth,
},
{
doc: `{"phone":"not-valid-number"}`,
schema: "file://./stub/extension/credentials/code.schema.json",
ct: identity.CredentialsTypeCodeAuth,
expectErr: errors.New("I[#/phone] S[#/properties/phone] validation failed\n I[#/phone] S[#/properties/phone/format] \"not-valid-number\" is not valid \"tel\"\n I[#/phone] S[#/properties/phone/format] the phone number supplied is not a number"),
},
{
doc: `{"phone":"+4407376494399"}`,
schema: "file://./stub/extension/credentials/code.schema.json",
expect: []string{"+447376494399"},
ct: identity.CredentialsTypeCodeAuth,
},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
c := jsonschema.NewCompiler()
Expand All @@ -103,12 +125,16 @@ func TestSchemaExtensionCredentials(t *testing.T) {
err = c.MustCompile(ctx, tc.schema).Validate(bytes.NewBufferString(tc.doc))
if tc.expectErr != nil {
require.EqualError(t, err, tc.expectErr.Error())
} else {
require.NoError(t, err)
}
require.NoError(t, e.Finish())

credentials, ok := i.GetCredentials(tc.ct)
require.True(t, ok)
assert.ElementsMatch(t, tc.expect, credentials.Identifiers)
if tc.expectErr == nil {
credentials, ok := i.GetCredentials(tc.ct)
require.True(t, ok)
assert.ElementsMatch(t, tc.expect, credentials.Identifiers)
}
})
}
}
30 changes: 24 additions & 6 deletions identity/extension_verification.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@
}

type SchemaExtensionVerification struct {
lifespan time.Duration
l sync.Mutex
v []VerifiableAddress
i *Identity
lifespan time.Duration
codeTestNumbers []string
l sync.Mutex
v []VerifiableAddress
i *Identity
}

func NewSchemaExtensionVerification(i *Identity, lifespan time.Duration) *SchemaExtensionVerification {
return &SchemaExtensionVerification{i: i, lifespan: lifespan}
func NewSchemaExtensionVerification(i *Identity, lifespan time.Duration, codeTestNumbers []string) *SchemaExtensionVerification {
return &SchemaExtensionVerification{i: i, lifespan: lifespan, codeTestNumbers: codeTestNumbers}
}

const (
Expand Down Expand Up @@ -122,3 +123,20 @@
}
return nil
}

func (r *SchemaExtensionVerification) checkTelFormat(ctx jsonschema.ValidationContext, value interface{}) error {

Check failure on line 127 in identity/extension_verification.go

View workflow job for this annotation

GitHub Actions / Run tests and lints

func `(*SchemaExtensionVerification).checkTelFormat` is unused (unused)

Check failure on line 127 in identity/extension_verification.go

View workflow job for this annotation

GitHub Actions / Run tests and lints

func `(*SchemaExtensionVerification).checkTelFormat` is unused (unused)
validationError := ctx.Error("format", "%q is not valid %q", value, "phone")
num, ok := value.(string)
if !ok {
return validationError
}
for _, n := range r.codeTestNumbers {
if num == n {
return nil
}
}
if !jsonschema.Formats["tel"](num) {
return validationError
}
return nil
}
2 changes: 1 addition & 1 deletion identity/extension_verification_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ func TestSchemaExtensionVerification(t *testing.T) {
runner, err := schema.NewExtensionRunner(ctx)
require.NoError(t, err)

e := NewSchemaExtensionVerification(id, time.Minute)
e := NewSchemaExtensionVerification(id, time.Minute, []string{})
runner.AddRunner(e).Register(c)

err = c.MustCompile(ctx, tc.schema).Validate(bytes.NewBufferString(tc.doc))
Expand Down
1 change: 1 addition & 0 deletions identity/identity_verification.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (

const (
VerifiableAddressTypeEmail VerifiableAddressType = AddressTypeEmail
VerifiableAddressTypePhone VerifiableAddressType = AddressTypePhone

VerifiableAddressStatusPending VerifiableAddressStatus = "pending"
VerifiableAddressStatusSent VerifiableAddressStatus = "sent"
Expand Down
Loading
Loading