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: support MFA via SMS #3682

Merged
merged 23 commits into from
Jan 26, 2024
Merged
Show file tree
Hide file tree
Changes from 22 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
8 changes: 6 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -175,15 +175,15 @@ docker:
DOCKER_BUILDKIT=1 DOCKER_CONTENT_TRUST=1 docker build -f .docker/Dockerfile-build --build-arg=COMMIT=$(VCS_REF) --build-arg=BUILD_DATE=$(BUILD_DATE) -t oryd/kratos:${IMAGE_TAG} .

.PHONY: test-e2e
test-e2e: node_modules test-resetdb
test-e2e: node_modules test-resetdb kratos-config-e2e
source script/test-envs.sh
test/e2e/run.sh sqlite
test/e2e/run.sh postgres
test/e2e/run.sh cockroach
test/e2e/run.sh mysql

.PHONY: test-e2e-playwright
test-e2e-playwright: node_modules test-resetdb
test-e2e-playwright: node_modules test-resetdb kratos-config-e2e
source script/test-envs.sh
test/e2e/run.sh --only-setup
(cd test/e2e; DB=memory npm run playwright)
Expand Down Expand Up @@ -212,3 +212,7 @@ licenses: .bin/licenses node_modules # checks open-source licenses
node_modules: package-lock.json
npm ci
touch node_modules

.PHONY: kratos-config-e2e
hperl marked this conversation as resolved.
Show resolved Hide resolved
kratos-config-e2e:
sh ./test/e2e/render-kratos-config.sh
7 changes: 5 additions & 2 deletions cmd/clidoc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ func init() {
"NewErrorValidationRegistrationRetrySuccessful": text.NewErrorValidationRegistrationRetrySuccessful(),
"NewInfoSelfServiceRegistrationRegisterCode": text.NewInfoSelfServiceRegistrationRegisterCode(),
"NewErrorValidationLoginLinkedCredentialsDoNotMatch": text.NewErrorValidationLoginLinkedCredentialsDoNotMatch(),
"NewErrorValidationAddressUnknown": text.NewErrorValidationAddressUnknown(),
"NewInfoSelfServiceLoginCodeMFA": text.NewInfoSelfServiceLoginCodeMFA(),
"NewInfoSelfServiceLoginCodeMFAHint": text.NewInfoSelfServiceLoginCodeMFAHint("{maskedIdentifier}"),
}
}

Expand Down Expand Up @@ -247,7 +250,7 @@ func writeMessages(path string, sortedMessages []*text.Message) error {
r := regexp.MustCompile(`(?s)<!-- START MESSAGE TABLE -->(.*?)<!-- END MESSAGE TABLE -->`)
result := r.ReplaceAllString(string(content), "<!-- START MESSAGE TABLE -->\n"+w.String()+"\n<!-- END MESSAGE TABLE -->")

f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755)
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
return err
}
Expand All @@ -266,7 +269,7 @@ func writeMessages(path string, sortedMessages []*text.Message) error {
func writeMessagesJson(path string, sortedMessages []*text.Message) error {
result := codeEncode(sortedMessages)

f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755)
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
return err
}
Expand Down
24 changes: 22 additions & 2 deletions contrib/quickstart/kratos/phone-password/identity.schema.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,32 @@
{
"$id": "https://schemas.ory.sh/presets/kratos/quickstart/email-password/identity.schema.json",
"$id": "https://schemas.ory.sh/presets/kratos/quickstart/phone-password/identity.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"title": "E-mail",
"minLength": 3,
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
},
"code": {
"identifier": true,
"via": "email"
}
},
"verification": {
"via": "email"
}
}
},
"phone": {
"type": "string",
"format": "tel",
Expand All @@ -24,7 +44,7 @@
}
}
},
"required": ["phone"],
"required": ["email", "phone"],
"additionalProperties": false
}
}
Expand Down
6 changes: 6 additions & 0 deletions courier/sms_templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ func NewSMSTemplateFromMessage(d template.Dependencies, m Message) (SMSTemplate,
return nil, err
}
return sms.NewTestStub(d, &t), nil
case template.TypeLoginCodeValid:
var t sms.LoginCodeValidModel
if err := json.Unmarshal(m.TemplateData, &t); err != nil {
return nil, err
}
return sms.NewLoginCodeValid(d, &t), nil

default:
return nil, errors.Errorf("received unexpected message template type: %s", m.TemplateType)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Your login code is: {{ .LoginCode }}
52 changes: 52 additions & 0 deletions courier/template/sms/login_code_valid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package sms

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

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

type (
LoginCodeValid struct {
deps template.Dependencies
model *LoginCodeValidModel
}
LoginCodeValidModel struct {
To string
LoginCode string
Identity map[string]interface{}
}
)

func NewLoginCodeValid(d template.Dependencies, m *LoginCodeValidModel) *LoginCodeValid {
jonas-jonas marked this conversation as resolved.
Show resolved Hide resolved
return &LoginCodeValid{deps: d, model: m}
}

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

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

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

func (t *LoginCodeValid) TemplateType() template.TemplateType {
return template.TypeLoginCodeValid
}
37 changes: 37 additions & 0 deletions courier/template/sms/login_code_valid_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package sms_test

import (
"context"
"fmt"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

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

func TestNewLoginCodeValid(t *testing.T) {
_, reg := internal.NewFastRegistryWithMocks(t)

const (
expectedPhone = "+12345678901"
otp = "012345"
)

tpl := sms.NewLoginCodeValid(reg, &sms.LoginCodeValidModel{To: expectedPhone, LoginCode: otp})

expectedBody := fmt.Sprintf("Your login code is: %s\n", otp)

actualBody, err := tpl.SMSBody(context.Background())
require.NoError(t, err)
assert.Equal(t, expectedBody, actualBody)

actualPhone, err := tpl.PhoneNumber()
require.NoError(t, err)
assert.Equal(t, expectedPhone, actualPhone)
}
18 changes: 17 additions & 1 deletion driver/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const (
ViperKeyCourierTemplatesVerificationCodeInvalidEmail = "courier.templates.verification_code.invalid.email"
ViperKeyCourierTemplatesVerificationCodeValidEmail = "courier.templates.verification_code.valid.email"
ViperKeyCourierTemplatesVerificationCodeValidSMS = "courier.templates.verification_code.valid.sms"
ViperKeyCourierTemplatesLoginCodeValidSMS = "courier.templates.login_code.valid.sms"
ViperKeyCourierDeliveryStrategy = "courier.delivery_strategy"
ViperKeyCourierHTTPRequestConfig = "courier.http.request_config"
ViperKeyCourierTemplatesLoginCodeValidEmail = "courier.templates.login_code.valid.email"
Expand Down Expand Up @@ -236,6 +237,7 @@ type (
SelfServiceStrategyCode struct {
*SelfServiceStrategy
PasswordlessEnabled bool `json:"passwordless_enabled"`
MFAEnabled bool `json:"mfa_enabled"`
}
Schema struct {
ID string `json:"id" koanf:"id"`
Expand Down Expand Up @@ -303,6 +305,7 @@ type (
CourierTemplatesLoginCodeValid(ctx context.Context) *CourierEmailTemplate
CourierTemplatesRegistrationCodeValid(ctx context.Context) *CourierEmailTemplate
CourierSMSTemplatesVerificationCodeValid(ctx context.Context) *CourierSMSTemplate
CourierSMSTemplatesLoginCodeValid(ctx context.Context) *CourierSMSTemplate
CourierMessageRetries(ctx context.Context) int
CourierWorkerPullCount(ctx context.Context) int
CourierWorkerPullWait(ctx context.Context) time.Duration
Expand Down Expand Up @@ -758,8 +761,16 @@ func (p *Config) SelfServiceStrategy(ctx context.Context, strategy string) *Self
case "code", "password", "profile":
defaultEnabled = true
}

// Backwards compatibility for the old "passwordless_enabled" key
// This force-enables the code strategy, if passwordless is enabled, because in earlier versions it was possible to
// disable the code strategy, but enable passwordless
enabled := pp.BoolF(basePath+".enabled", defaultEnabled)
if strategy == "code" {
enabled = enabled || pp.Bool(basePath+".passwordless_enabled")
}
return &SelfServiceStrategy{
Enabled: pp.BoolF(basePath+".enabled", defaultEnabled),
Enabled: enabled,
Config: config,
}
}
Expand All @@ -782,6 +793,7 @@ func (p *Config) SelfServiceCodeStrategy(ctx context.Context) *SelfServiceStrate
Config: config,
},
PasswordlessEnabled: pp.BoolF(basePath+".passwordless_enabled", false),
MFAEnabled: pp.BoolF(basePath+".mfa_enabled", false),
jonas-jonas marked this conversation as resolved.
Show resolved Hide resolved
}
}

Expand Down Expand Up @@ -1115,6 +1127,10 @@ func (p *Config) CourierSMSTemplatesVerificationCodeValid(ctx context.Context) *
return p.CourierSMSTemplatesHelper(ctx, ViperKeyCourierTemplatesVerificationCodeValidSMS)
}

func (p *Config) CourierSMSTemplatesLoginCodeValid(ctx context.Context) *CourierSMSTemplate {
return p.CourierSMSTemplatesHelper(ctx, ViperKeyCourierTemplatesLoginCodeValidSMS)
}

func (p *Config) CourierTemplatesLoginCodeValid(ctx context.Context) *CourierEmailTemplate {
return p.CourierEmailTemplatesHelper(ctx, ViperKeyCourierTemplatesLoginCodeValidEmail)
}
Expand Down
14 changes: 2 additions & 12 deletions driver/registry_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,21 +327,11 @@ func (m *RegistryDefault) selfServiceStrategies() []any {
}

func (m *RegistryDefault) strategyRegistrationEnabled(ctx context.Context, id string) bool {
switch id {
case identity.CredentialsTypeCodeAuth.String():
return m.Config().SelfServiceCodeStrategy(ctx).PasswordlessEnabled
default:
return m.Config().SelfServiceStrategy(ctx, id).Enabled
}
return m.Config().SelfServiceStrategy(ctx, id).Enabled
jonas-jonas marked this conversation as resolved.
Show resolved Hide resolved
}

func (m *RegistryDefault) strategyLoginEnabled(ctx context.Context, id string) bool {
switch id {
case identity.CredentialsTypeCodeAuth.String():
return m.Config().SelfServiceCodeStrategy(ctx).PasswordlessEnabled
default:
return m.Config().SelfServiceStrategy(ctx, id).Enabled
}
return m.Config().SelfServiceStrategy(ctx, id).Enabled
hperl marked this conversation as resolved.
Show resolved Hide resolved
}

func (m *RegistryDefault) RegistrationStrategies(ctx context.Context, filters ...registration.StrategyFilter) (registrationStrategies registration.Strategies) {
Expand Down
Loading
Loading