Merge branch 'next' into next-rc

# Conflicts:
#	cmd/setup/config.go
#	cmd/setup/setup.go
This commit is contained in:
Livio Spring 2025-01-16 06:31:39 +01:00
commit 955f1c0808
No known key found for this signature in database
GPG Key ID: 26BB1C2FA5952CF0
37 changed files with 839 additions and 126 deletions

View File

@ -198,8 +198,11 @@ Caches:
AutoPrune:
Interval: 1m
TimeOut: 5s
# Postgres connector uses the configured database (postgres or cockraochdb) as cache.
# It is suitable for deployments with multiple containers.
# The cache is enabled by default because it is the default cache states for IdP form callbacks
Postgres:
Enabled: false
Enabled: true
AutoPrune:
Interval: 15m
TimeOut: 30s
@ -311,7 +314,7 @@ Caches:
# When connector is empty, this cache will be disabled.
Connector: ""
MaxAge: 1h
LastUsage: 10m
LastUseAge: 10m
# Log enables cache-specific logging. Default to error log to stderr when omitted.
Log:
Level: error
@ -322,7 +325,7 @@ Caches:
Milestones:
Connector: ""
MaxAge: 1h
LastUsage: 10m
LastUseAge: 10m
Log:
Level: error
AddSource: true
@ -332,7 +335,17 @@ Caches:
Organization:
Connector: ""
MaxAge: 1h
LastUsage: 10m
LastUseAge: 10m
Log:
Level: error
AddSource: true
Formatter:
Format: text
# IdP callbacks using form POST cache, required for handling them securely and without possible too big request urls.
IdPFormCallbacks:
Connector: "postgres"
MaxAge: 1h
LastUseAge: 10m
Log:
Level: error
AddSource: true

View File

@ -48,5 +48,5 @@ func (mig *InitPushFunc) Execute(ctx context.Context, _ eventstore.Event) (err e
}
func (mig *InitPushFunc) String() string {
return "40_init_push_func"
return "40_init_push_func_v2"
}

View File

@ -1,82 +1,92 @@
CREATE OR REPLACE FUNCTION eventstore.commands_to_events(commands eventstore.command[]) RETURNS SETOF eventstore.events2 VOLATILE AS $$
SELECT
c.instance_id
, c.aggregate_type
, c.aggregate_id
, c.command_type AS event_type
, cs.sequence + ROW_NUMBER() OVER (PARTITION BY c.instance_id, c.aggregate_type, c.aggregate_id ORDER BY c.in_tx_order) AS sequence
, c.revision
, NOW() AS created_at
, c.payload
, c.creator
, cs.owner
, EXTRACT(EPOCH FROM NOW()) AS position
, c.in_tx_order
FROM (
SELECT
c.instance_id
, c.aggregate_type
, c.aggregate_id
, c.command_type
, c.revision
, c.payload
, c.creator
, c.owner
, ROW_NUMBER() OVER () AS in_tx_order
FROM
UNNEST(commands) AS c
) AS c
JOIN (
SELECT
cmds.instance_id
, cmds.aggregate_type
, cmds.aggregate_id
, CASE WHEN (e.owner IS NOT NULL OR e.owner <> '') THEN e.owner ELSE command_owners.owner END AS owner
, COALESCE(MAX(e.sequence), 0) AS sequence
FROM (
CREATE OR REPLACE FUNCTION eventstore.latest_aggregate_state(
instance_id TEXT
, aggregate_type TEXT
, aggregate_id TEXT
, sequence OUT BIGINT
, owner OUT TEXT
)
LANGUAGE 'plpgsql'
STABLE PARALLEL SAFE
AS $$
BEGIN
SELECT
COALESCE(e.sequence, 0) AS sequence
, e.owner
INTO
sequence
, owner
FROM
eventstore.events2 e
WHERE
e.instance_id = $1
AND e.aggregate_type = $2
AND e.aggregate_id = $3
ORDER BY
e.sequence DESC
LIMIT 1;
RETURN;
END;
$$;
CREATE OR REPLACE FUNCTION eventstore.commands_to_events(commands eventstore.command[])
RETURNS SETOF eventstore.events2
LANGUAGE 'plpgsql'
STABLE PARALLEL SAFE
ROWS 10
AS $$
DECLARE
"aggregate" RECORD;
current_sequence BIGINT;
current_owner TEXT;
BEGIN
FOR "aggregate" IN
SELECT DISTINCT
instance_id
, aggregate_type
, aggregate_id
, owner
FROM UNNEST(commands)
) AS cmds
LEFT JOIN eventstore.events2 AS e
ON cmds.instance_id = e.instance_id
AND cmds.aggregate_type = e.aggregate_type
AND cmds.aggregate_id = e.aggregate_id
JOIN (
LOOP
SELECT
*
INTO
current_sequence
, current_owner
FROM eventstore.latest_aggregate_state(
"aggregate".instance_id
, "aggregate".aggregate_type
, "aggregate".aggregate_id
);
RETURN QUERY
SELECT
DISTINCT ON (
instance_id
, aggregate_type
, aggregate_id
)
instance_id
, aggregate_type
, aggregate_id
, owner
c.instance_id
, c.aggregate_type
, c.aggregate_id
, c.command_type -- AS event_type
, COALESCE(current_sequence, 0) + ROW_NUMBER() OVER () -- AS sequence
, c.revision
, NOW() -- AS created_at
, c.payload
, c.creator
, COALESCE(current_owner, c.owner) -- AS owner
, EXTRACT(EPOCH FROM NOW()) -- AS position
, c.ordinality::INT -- AS in_tx_order
FROM
UNNEST(commands)
) AS command_owners ON
cmds.instance_id = command_owners.instance_id
AND cmds.aggregate_type = command_owners.aggregate_type
AND cmds.aggregate_id = command_owners.aggregate_id
GROUP BY
cmds.instance_id
, cmds.aggregate_type
, cmds.aggregate_id
, 4 -- owner
) AS cs
ON c.instance_id = cs.instance_id
AND c.aggregate_type = cs.aggregate_type
AND c.aggregate_id = cs.aggregate_id
ORDER BY
in_tx_order;
$$ LANGUAGE SQL;
UNNEST(commands) WITH ORDINALITY AS c
WHERE
c.instance_id = aggregate.instance_id
AND c.aggregate_type = aggregate.aggregate_type
AND c.aggregate_id = aggregate.aggregate_id;
END LOOP;
RETURN;
END;
$$;
CREATE OR REPLACE FUNCTION eventstore.push(commands eventstore.command[]) RETURNS SETOF eventstore.events2 VOLATILE AS $$
INSERT INTO eventstore.events2
SELECT * FROM eventstore.commands_to_events(commands)
ORDER BY in_tx_order
RETURNING *
$$ LANGUAGE SQL;

40
cmd/setup/43.go Normal file
View File

@ -0,0 +1,40 @@
package setup
import (
"context"
"embed"
"fmt"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore"
)
var (
//go:embed 43/cockroach/*.sql
//go:embed 43/postgres/*.sql
createFieldsDomainIndex embed.FS
)
type CreateFieldsDomainIndex struct {
dbClient *database.DB
}
func (mig *CreateFieldsDomainIndex) Execute(ctx context.Context, _ eventstore.Event) error {
statements, err := readStatements(createFieldsDomainIndex, "43", mig.dbClient.Type())
if err != nil {
return err
}
for _, stmt := range statements {
logging.WithFields("file", stmt.file, "migration", mig.String()).Info("execute statement")
if _, err := mig.dbClient.ExecContext(ctx, stmt.query); err != nil {
return fmt.Errorf("%s %s: %w", mig.String(), stmt.file, err)
}
}
return nil
}
func (mig *CreateFieldsDomainIndex) String() string {
return "43_create_fields_domain_index"
}

View File

@ -0,0 +1,3 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS fields_instance_domains_idx
ON eventstore.fields (object_id)
WHERE object_type = 'instance_domain' AND field_name = 'domain';

View File

@ -0,0 +1,3 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS fields_instance_domains_idx
ON eventstore.fields (object_id) INCLUDE (instance_id)
WHERE object_type = 'instance_domain' AND field_name = 'domain';

39
cmd/setup/44.go Normal file
View File

@ -0,0 +1,39 @@
package setup
import (
"context"
"embed"
"fmt"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore"
)
var (
//go:embed 44/*.sql
replaceCurrentSequencesIndex embed.FS
)
type ReplaceCurrentSequencesIndex struct {
dbClient *database.DB
}
func (mig *ReplaceCurrentSequencesIndex) Execute(ctx context.Context, _ eventstore.Event) error {
statements, err := readStatements(replaceCurrentSequencesIndex, "44", "")
if err != nil {
return err
}
for _, stmt := range statements {
logging.WithFields("file", stmt.file, "migration", mig.String()).Info("execute statement")
if _, err := mig.dbClient.ExecContext(ctx, stmt.query); err != nil {
return fmt.Errorf("%s %s: %w", mig.String(), stmt.file, err)
}
}
return nil
}
func (mig *ReplaceCurrentSequencesIndex) String() string {
return "44_replace_current_sequences_index"
}

View File

@ -0,0 +1,3 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS events2_current_sequence2
ON eventstore.events2 USING btree
(aggregate_id ASC, aggregate_type ASC, instance_id ASC, sequence DESC);

View File

@ -0,0 +1 @@
DROP INDEX IF EXISTS eventstore.events2_current_sequence;

View File

@ -128,6 +128,8 @@ type Steps struct {
s38BackChannelLogoutNotificationStart *BackChannelLogoutNotificationStart
s40InitPushFunc *InitPushFunc
s42Apps7OIDCConfigsLoginVersion *Apps7OIDCConfigsLoginVersion
s43CreateFieldsDomainIndex *CreateFieldsDomainIndex
s44ReplaceCurrentSequencesIndex *ReplaceCurrentSequencesIndex
}
func MustNewSteps(v *viper.Viper) *Steps {

View File

@ -171,6 +171,8 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
steps.s38BackChannelLogoutNotificationStart = &BackChannelLogoutNotificationStart{dbClient: esPusherDBClient, esClient: eventstoreClient}
steps.s40InitPushFunc = &InitPushFunc{dbClient: esPusherDBClient}
steps.s42Apps7OIDCConfigsLoginVersion = &Apps7OIDCConfigsLoginVersion{dbClient: esPusherDBClient}
steps.s43CreateFieldsDomainIndex = &CreateFieldsDomainIndex{dbClient: queryDBClient}
steps.s44ReplaceCurrentSequencesIndex = &ReplaceCurrentSequencesIndex{dbClient: esPusherDBClient}
err = projection.Create(ctx, projectionDBClient, eventstoreClient, config.Projections, nil, nil, nil)
logging.OnError(err).Fatal("unable to start projections")
@ -224,6 +226,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
steps.s35AddPositionToIndexEsWm,
steps.s36FillV2Milestones,
steps.s38BackChannelLogoutNotificationStart,
steps.s44ReplaceCurrentSequencesIndex,
} {
mustExecuteMigration(ctx, eventstoreClient, step, "migration failed")
}
@ -242,6 +245,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
steps.s33SMSConfigs3TwilioAddVerifyServiceSid,
steps.s37Apps7OIDConfigsBackChannelLogoutURI,
steps.s42Apps7OIDCConfigsLoginVersion,
steps.s43CreateFieldsDomainIndex,
} {
mustExecuteMigration(ctx, eventstoreClient, step, "migration failed")
}

View File

@ -317,6 +317,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
authZRepo,
keys,
permissionCheck,
cacheConnectors,
)
if err != nil {
return err
@ -361,6 +362,7 @@ func startAPIs(
authZRepo authz_repo.Repository,
keys *encryption.EncryptionKeys,
permissionCheck domain.PermissionCheck,
cacheConnectors connector.Connectors,
) (*api.API, error) {
repo := struct {
authz_repo.Repository
@ -542,6 +544,7 @@ func startAPIs(
keys.User,
keys.IDPConfig,
keys.CSRFCookieKey,
cacheConnectors,
)
if err != nil {
return nil, fmt.Errorf("unable to start login: %w", err)

View File

@ -1,4 +1,3 @@
version: '3.8'
services:
traefik:

View File

@ -1,5 +1,3 @@
version: "3.8"
services:
zitadel:
restart: "always"

View File

@ -24,7 +24,7 @@ export const Description = ({mode, link}) => {
}
export const Commands = ({mode, name, lower, configfilename}) => {
let genCert = '# Generate a self signed certificate and key.\nopenssl req -x509 -batch -subj "/CN=127.0.0.1.sslip.io/O=ZITADEL Demo" -nodes -newkey rsa:2048 -keyout ./selfsigned.key -out ./selfsigned.crt\n\n';
let genCert = '# Generate a self signed certificate and key.\nopenssl req -x509 -batch -subj "/CN=127.0.0.1.sslip.io/O=ZITADEL Demo" -nodes -newkey rsa:2048 -keyout ./selfsigned.key -out ./selfsigned.crt 2>/dev/null\n\n';
let connPort = "443"
let connInsecureFlag = "--insecure "
let connScheme = "https"
@ -42,16 +42,16 @@ export const Commands = ({mode, name, lower, configfilename}) => {
<CodeBlock language="bash">
{'# Download the configuration files.'}{'\n'}
{'export ZITADEL_CONFIG_FILES=https://raw.githubusercontent.com/zitadel/zitadel/main/docs/docs/self-hosting/manage/reverseproxy\n'}
{`wget $\{ZITADEL_CONFIG_FILES\}/docker-compose.yaml -O docker-compose-base.yaml`}{'\n'}
{'wget $\{ZITADEL_CONFIG_FILES\}/'}{lower}{'/docker-compose.yaml -O docker-compose-'}{lower}{'.yaml'}{'\n'}
{'wget $\{ZITADEL_CONFIG_FILES\}/'}{lower}{'/'}{configfilename}{' -O '}{configfilename}{'\n'}
{'wget $\{ZITADEL_CONFIG_FILES\}/docker-compose.yaml -O docker-compose-base.yaml --quiet \n'}
{'wget $\{ZITADEL_CONFIG_FILES\}/'}{lower}{'/docker-compose.yaml -O docker-compose-'}{lower}{'.yaml --quiet \n'}
{'wget $\{ZITADEL_CONFIG_FILES\}/'}{lower}{'/'}{configfilename}{' -O '}{configfilename}{' --quiet \n'}
{'\n'}
{genCert}
{'# Run the database, ZITADEL and '}{name}{'.'}{'\n'}
{'docker compose --file docker-compose-base.yaml --file docker-compose-'}{lower}{'.yaml up --detach proxy-'}{mode}{'-tls'}{'\n'}
{'docker compose --file docker-compose-base.yaml --file docker-compose-'}{lower}{'.yaml up --detach --wait db zitadel-init zitadel-'}{mode}{'-tls proxy-'}{mode}{'-tls'}{'\n'}
{'\n'}
{'# Test that gRPC and HTTP APIs work. Empty brackets like {} means success.\n'}
{'sleep 3\n'}
{'# Make sure you have the grpcurl cli installed on your machine https://github.com/fullstorydev/grpcurl?tab=readme-ov-file#installation\n'}
{'grpcurl '}{connInsecureFlag}{grpcPlainTextFlag}{'127.0.0.1.sslip.io:'}{connPort}{' zitadel.admin.v1.AdminService/Healthz\n'}
{'curl '}{connInsecureFlag}{connScheme}{'://127.0.0.1.sslip.io:'}{connPort}{'/admin/v1/healthz\n'}
</CodeBlock>

View File

@ -1,5 +1,3 @@
version: '3.8'
services:
proxy-disabled-tls:

View File

@ -1,5 +1,3 @@
version: '3.8'
services:
zitadel-disabled-tls:
@ -17,7 +15,7 @@ services:
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel_user
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel_pw
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: root
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
networks:
@ -43,16 +41,16 @@ services:
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel_user
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel_pw
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: root
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
networks:
- 'zitadel'
depends_on:
zitadel-init:
condition: 'service_completed_successfully'
db:
condition: 'service_healthy'
zitadel-init:
condition: 'service_completed_successfully'
zitadel-enabled-tls:
extends:
@ -71,7 +69,7 @@ services:
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel_user
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel_pw
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: root
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
volumes:
@ -109,7 +107,7 @@ services:
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel_user
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel_pw
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: root
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
networks:
@ -125,10 +123,9 @@ services:
restart: 'always'
image: postgres:16-alpine
environment:
PGUSER: root
POSTGRES_PASSWORD: postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready", "-d", "zitadel", "-U", "postgres"]
test: ["CMD-SHELL", "pg_isready"]
interval: 5s
timeout: 60s
retries: 10

View File

@ -1,5 +1,3 @@
version: '3.8'
services:
proxy-disabled-tls:

View File

@ -1,5 +1,3 @@
version: '3.8'
services:
proxy-disabled-tls:

View File

@ -1,5 +1,3 @@
version: '3.8'
services:
proxy-disabled-tls:

View File

@ -1,5 +1,3 @@
version: '3.8'
services:
db:

View File

@ -1,5 +1,3 @@
version: '3.8'
services:
zitadel:
user: '$UID'

View File

@ -1,5 +1,3 @@
version: '3.8'
services:
zitadel:
extends:

View File

@ -9,6 +9,7 @@ import (
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/pkg/grpc/object/v2"
user_pb "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
func DomainToDetailsPb(objectDetail *domain.ObjectDetails) *object.Details {
@ -70,3 +71,105 @@ func TextMethodToQuery(method object.TextQueryMethod) query.TextComparison {
return -1
}
}
func AuthMethodsToPb(mfas *query.AuthMethods) []*user_pb.AuthFactor {
factors := make([]*user_pb.AuthFactor, len(mfas.AuthMethods))
for i, mfa := range mfas.AuthMethods {
factors[i] = AuthMethodToPb(mfa)
}
return factors
}
func AuthMethodToPb(mfa *query.AuthMethod) *user_pb.AuthFactor {
factor := &user_pb.AuthFactor{
State: MFAStateToPb(mfa.State),
}
switch mfa.Type {
case domain.UserAuthMethodTypeTOTP:
factor.Type = &user_pb.AuthFactor_Otp{
Otp: &user_pb.AuthFactorOTP{},
}
case domain.UserAuthMethodTypeU2F:
factor.Type = &user_pb.AuthFactor_U2F{
U2F: &user_pb.AuthFactorU2F{
Id: mfa.TokenID,
Name: mfa.Name,
},
}
case domain.UserAuthMethodTypeOTPSMS:
factor.Type = &user_pb.AuthFactor_OtpSms{
OtpSms: &user_pb.AuthFactorOTPSMS{},
}
case domain.UserAuthMethodTypeOTPEmail:
factor.Type = &user_pb.AuthFactor_OtpEmail{
OtpEmail: &user_pb.AuthFactorOTPEmail{},
}
case domain.UserAuthMethodTypeUnspecified:
case domain.UserAuthMethodTypePasswordless:
case domain.UserAuthMethodTypePassword:
case domain.UserAuthMethodTypeIDP:
case domain.UserAuthMethodTypeOTP:
case domain.UserAuthMethodTypePrivateKey:
}
return factor
}
func AuthFactorsToPb(authFactors []user_pb.AuthFactors) []domain.UserAuthMethodType {
factors := make([]domain.UserAuthMethodType, len(authFactors))
for i, authFactor := range authFactors {
factors[i] = AuthFactorToPb(authFactor)
}
return factors
}
func AuthFactorToPb(authFactor user_pb.AuthFactors) domain.UserAuthMethodType {
switch authFactor {
case user_pb.AuthFactors_OTP:
return domain.UserAuthMethodTypeTOTP
case user_pb.AuthFactors_OTP_SMS:
return domain.UserAuthMethodTypeOTPSMS
case user_pb.AuthFactors_OTP_EMAIL:
return domain.UserAuthMethodTypeOTPEmail
case user_pb.AuthFactors_U2F:
return domain.UserAuthMethodTypeU2F
default:
return domain.UserAuthMethodTypeUnspecified
}
}
func AuthFactorStatesToPb(authFactorStates []user_pb.AuthFactorState) []domain.MFAState {
factorStates := make([]domain.MFAState, len(authFactorStates))
for i, authFactorState := range authFactorStates {
factorStates[i] = AuthFactorStateToPb(authFactorState)
}
return factorStates
}
func AuthFactorStateToPb(authFactorState user_pb.AuthFactorState) domain.MFAState {
switch authFactorState {
case user_pb.AuthFactorState_AUTH_FACTOR_STATE_UNSPECIFIED:
return domain.MFAStateUnspecified
case user_pb.AuthFactorState_AUTH_FACTOR_STATE_NOT_READY:
return domain.MFAStateNotReady
case user_pb.AuthFactorState_AUTH_FACTOR_STATE_READY:
return domain.MFAStateReady
case user_pb.AuthFactorState_AUTH_FACTOR_STATE_REMOVED:
return domain.MFAStateRemoved
default:
return domain.MFAStateUnspecified
}
}
func MFAStateToPb(state domain.MFAState) user_pb.AuthFactorState {
switch state {
case domain.MFAStateNotReady:
return user_pb.AuthFactorState_AUTH_FACTOR_STATE_NOT_READY
case domain.MFAStateReady:
return user_pb.AuthFactorState_AUTH_FACTOR_STATE_READY
case domain.MFAStateUnspecified, domain.MFAStateRemoved:
// Handle all remaining cases so the linter succeeds
return user_pb.AuthFactorState_AUTH_FACTOR_STATE_UNSPECIFIED
default:
return user_pb.AuthFactorState_AUTH_FACTOR_STATE_UNSPECIFIED
}
}

View File

@ -10,6 +10,8 @@ import (
"testing"
"time"
"github.com/zitadel/logging"
"github.com/brianvoe/gofakeit/v6"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
@ -2629,6 +2631,247 @@ func TestServer_ListAuthenticationMethodTypes(t *testing.T) {
}
}
func TestServer_ListAuthenticationFactors(t *testing.T) {
tests := []struct {
name string
args *user.ListAuthenticationFactorsRequest
want *user.ListAuthenticationFactorsResponse
dep func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse)
wantErr bool
ctx context.Context
}{
{
name: "no auth",
args: &user.ListAuthenticationFactorsRequest{},
want: &user.ListAuthenticationFactorsResponse{
Result: nil,
},
dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) {
userIDWithoutAuth := Instance.CreateHumanUser(CTX).GetUserId()
args.UserId = userIDWithoutAuth
},
ctx: CTX,
},
{
name: "with u2f",
args: &user.ListAuthenticationFactorsRequest{},
want: &user.ListAuthenticationFactorsResponse{
Result: []*user.AuthFactor{
{
State: user.AuthFactorState_AUTH_FACTOR_STATE_READY,
},
},
},
dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) {
userWithU2F := Instance.CreateHumanUser(CTX).GetUserId()
U2FId := Instance.RegisterUserU2F(CTX, userWithU2F)
args.UserId = userWithU2F
want.Result[0].Type = &user.AuthFactor_U2F{
U2F: &user.AuthFactorU2F{
Id: U2FId,
Name: "nice name",
},
}
},
ctx: CTX,
},
{
name: "with totp, u2f",
args: &user.ListAuthenticationFactorsRequest{},
want: &user.ListAuthenticationFactorsResponse{
Result: []*user.AuthFactor{
{
State: user.AuthFactorState_AUTH_FACTOR_STATE_READY,
Type: &user.AuthFactor_Otp{
Otp: &user.AuthFactorOTP{},
},
},
{
State: user.AuthFactorState_AUTH_FACTOR_STATE_READY,
},
},
},
dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) {
userWithTOTP := Instance.CreateHumanUserWithTOTP(CTX, "secret").GetUserId()
U2FIdWithTOTP := Instance.RegisterUserU2F(CTX, userWithTOTP)
args.UserId = userWithTOTP
want.Result[1].Type = &user.AuthFactor_U2F{
U2F: &user.AuthFactorU2F{
Id: U2FIdWithTOTP,
Name: "nice name",
},
}
},
ctx: CTX,
},
{
name: "with totp, u2f filtered",
args: &user.ListAuthenticationFactorsRequest{
AuthFactors: []user.AuthFactors{user.AuthFactors_U2F},
},
want: &user.ListAuthenticationFactorsResponse{
Result: []*user.AuthFactor{
{
State: user.AuthFactorState_AUTH_FACTOR_STATE_READY,
},
},
},
dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) {
userWithTOTP := Instance.CreateHumanUserWithTOTP(CTX, "secret").GetUserId()
U2FIdWithTOTP := Instance.RegisterUserU2F(CTX, userWithTOTP)
args.UserId = userWithTOTP
want.Result[0].Type = &user.AuthFactor_U2F{
U2F: &user.AuthFactorU2F{
Id: U2FIdWithTOTP,
Name: "nice name",
},
}
},
ctx: CTX,
},
{
name: "with sms",
args: &user.ListAuthenticationFactorsRequest{},
want: &user.ListAuthenticationFactorsResponse{
Result: []*user.AuthFactor{
{
State: user.AuthFactorState_AUTH_FACTOR_STATE_READY,
Type: &user.AuthFactor_OtpSms{
OtpSms: &user.AuthFactorOTPSMS{},
},
},
},
},
dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) {
userWithSMS := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.GetId(), gofakeit.Email(), gofakeit.Phone()).GetUserId()
Instance.RegisterUserOTPSMS(CTX, userWithSMS)
args.UserId = userWithSMS
},
ctx: CTX,
},
{
name: "with email",
args: &user.ListAuthenticationFactorsRequest{},
want: &user.ListAuthenticationFactorsResponse{
Result: []*user.AuthFactor{
{
State: user.AuthFactorState_AUTH_FACTOR_STATE_READY,
Type: &user.AuthFactor_OtpEmail{
OtpEmail: &user.AuthFactorOTPEmail{},
},
},
},
},
dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) {
userWithEmail := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.GetId(), gofakeit.Email(), gofakeit.Phone()).GetUserId()
Instance.RegisterUserOTPEmail(CTX, userWithEmail)
args.UserId = userWithEmail
},
ctx: CTX,
},
{
name: "with not ready u2f",
args: &user.ListAuthenticationFactorsRequest{},
want: &user.ListAuthenticationFactorsResponse{
Result: []*user.AuthFactor{},
},
dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) {
userWithNotReadyU2F := Instance.CreateHumanUser(CTX).GetUserId()
_, err := Instance.Client.UserV2.RegisterU2F(CTX, &user.RegisterU2FRequest{
UserId: userWithNotReadyU2F,
Domain: Instance.Domain,
})
logging.OnError(err).Panic("Could not register u2f")
args.UserId = userWithNotReadyU2F
},
ctx: CTX,
},
{
name: "with not ready u2f state filtered",
args: &user.ListAuthenticationFactorsRequest{
States: []user.AuthFactorState{user.AuthFactorState_AUTH_FACTOR_STATE_NOT_READY},
},
want: &user.ListAuthenticationFactorsResponse{
Result: []*user.AuthFactor{
{
State: user.AuthFactorState_AUTH_FACTOR_STATE_NOT_READY,
},
},
},
dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) {
userWithNotReadyU2F := Instance.CreateHumanUser(CTX).GetUserId()
U2FNotReady, err := Instance.Client.UserV2.RegisterU2F(CTX, &user.RegisterU2FRequest{
UserId: userWithNotReadyU2F,
Domain: Instance.Domain,
})
logging.OnError(err).Panic("Could not register u2f")
args.UserId = userWithNotReadyU2F
want.Result[0].Type = &user.AuthFactor_U2F{
U2F: &user.AuthFactorU2F{
Id: U2FNotReady.GetU2FId(),
Name: "",
},
}
},
ctx: CTX,
},
{
name: "with no userId",
args: &user.ListAuthenticationFactorsRequest{
UserId: "",
},
ctx: CTX,
wantErr: true,
},
{
name: "with no permission",
args: &user.ListAuthenticationFactorsRequest{},
dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) {
userWithTOTP := Instance.CreateHumanUserWithTOTP(CTX, "totp").GetUserId()
args.UserId = userWithTOTP
},
ctx: UserCTX,
wantErr: true,
},
{
name: "with unknown user",
args: &user.ListAuthenticationFactorsRequest{
UserId: "unknown",
},
want: &user.ListAuthenticationFactorsResponse{},
ctx: CTX,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.dep != nil {
tt.dep(tt.args, tt.want)
}
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute)
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
got, err := Client.ListAuthenticationFactors(tt.ctx, tt.args)
if tt.wantErr {
require.Error(ttt, err)
return
}
require.NoError(ttt, err)
assert.ElementsMatch(t, tt.want.GetResult(), got.GetResult())
}, retryDuration, tick, "timeout waiting for expected auth methods result")
})
}
}
func TestServer_CreateInviteCode(t *testing.T) {
type args struct {
ctx context.Context

View File

@ -597,6 +597,39 @@ func (s *Server) ListAuthenticationMethodTypes(ctx context.Context, req *user.Li
}, nil
}
func (s *Server) ListAuthenticationFactors(ctx context.Context, req *user.ListAuthenticationFactorsRequest) (*user.ListAuthenticationFactorsResponse, error) {
query := new(query.UserAuthMethodSearchQueries)
if err := query.AppendUserIDQuery(req.UserId); err != nil {
return nil, err
}
authMethodsType := []domain.UserAuthMethodType{domain.UserAuthMethodTypeU2F, domain.UserAuthMethodTypeTOTP, domain.UserAuthMethodTypeOTPSMS, domain.UserAuthMethodTypeOTPEmail}
if len(req.GetAuthFactors()) > 0 {
authMethodsType = object.AuthFactorsToPb(req.GetAuthFactors())
}
if err := query.AppendAuthMethodsQuery(authMethodsType...); err != nil {
return nil, err
}
states := []domain.MFAState{domain.MFAStateReady}
if len(req.GetStates()) > 0 {
states = object.AuthFactorStatesToPb(req.GetStates())
}
if err := query.AppendStatesQuery(states...); err != nil {
return nil, err
}
authMethods, err := s.query.SearchUserAuthMethods(ctx, query, s.checkPermission)
if err != nil {
return nil, err
}
return &user.ListAuthenticationFactorsResponse{
Result: object.AuthMethodsToPb(authMethods),
}, nil
}
func authMethodTypesToPb(methodTypes []domain.UserAuthMethodType) []user.AuthenticationMethodType {
methods := make([]user.AuthenticationMethodType, len(methodTypes))
for i, method := range methodTypes {

View File

@ -3,6 +3,7 @@ package login
import (
"context"
"net/http"
"net/url"
"strings"
"github.com/crewjam/saml/samlsp"
@ -36,6 +37,9 @@ import (
const (
queryIDPConfigID = "idpConfigID"
queryState = "state"
queryRelayState = "RelayState"
queryMethod = "method"
tmplExternalNotFoundOption = "externalnotfoundoption"
)
@ -214,13 +218,36 @@ func (l *Login) handleExternalLoginCallbackForm(w http.ResponseWriter, r *http.R
l.renderLogin(w, r, nil, err)
return
}
r.Form.Add("Method", http.MethodPost)
http.Redirect(w, r, HandlerPrefix+EndpointExternalLoginCallback+"?"+r.Form.Encode(), 302)
state := r.Form.Get(queryState)
if state == "" {
state = r.Form.Get(queryRelayState)
}
if state == "" {
l.renderLogin(w, r, nil, zerrors.ThrowInvalidArgument(nil, "LOGIN-dsg3f", "Errors.AuthRequest.NotFound"))
return
}
l.caches.idpFormCallbacks.Set(r.Context(), &idpFormCallback{
InstanceID: authz.GetInstance(r.Context()).InstanceID(),
State: state,
Form: r.Form,
})
v := url.Values{}
v.Set(queryMethod, http.MethodPost)
v.Set(queryState, state)
http.Redirect(w, r, HandlerPrefix+EndpointExternalLoginCallback+"?"+v.Encode(), 302)
}
// handleExternalLoginCallback handles the callback from a IDP
// and tries to extract the user with the provided data
func (l *Login) handleExternalLoginCallback(w http.ResponseWriter, r *http.Request) {
// workaround because of CSRF on external identity provider flows using form_post
if r.URL.Query().Get(queryMethod) == http.MethodPost {
if err := l.setDataFromFormCallback(r, r.URL.Query().Get(queryState)); err != nil {
l.renderLogin(w, r, nil, err)
return
}
}
data := new(externalIDPCallbackData)
err := l.getParseData(r, data)
if err != nil {
@ -230,11 +257,6 @@ func (l *Login) handleExternalLoginCallback(w http.ResponseWriter, r *http.Reque
if data.State == "" {
data.State = data.RelayState
}
// workaround because of CSRF on external identity provider flows
if data.Method == http.MethodPost {
r.Method = http.MethodPost
r.PostForm = r.Form
}
userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context())
authReq, err := l.authRepo.AuthRequestByID(r.Context(), data.State, userAgentID)
@ -345,6 +367,25 @@ func (l *Login) handleExternalLoginCallback(w http.ResponseWriter, r *http.Reque
l.handleExternalUserAuthenticated(w, r, authReq, identityProvider, session, user, l.renderNextStep)
}
func (l *Login) setDataFromFormCallback(r *http.Request, state string) error {
r.Method = http.MethodPost
err := r.ParseForm()
if err != nil {
return err
}
// fallback to the form data in case the request was started before the cache was implemented
r.PostForm = r.Form
idpCallback, ok := l.caches.idpFormCallbacks.Get(r.Context(), idpFormCallbackIndexRequestID,
idpFormCallbackKey(authz.GetInstance(r.Context()).InstanceID(), state))
if ok {
r.PostForm = idpCallback.Form
// We need to set the form as well to make sure the data is parsed correctly.
// Form precedes PostForm in the parsing order.
r.Form = idpCallback.Form
}
return nil
}
func (l *Login) tryMigrateExternalUserID(r *http.Request, session idp.Session, authReq *domain.AuthRequest, externalUser *domain.ExternalUser) (previousIDMatched bool, err error) {
migration, ok := session.(idp.SessionSupportsMigration)
if !ok {

View File

@ -3,6 +3,7 @@ package login
import (
"context"
"net/http"
"net/url"
"strings"
"time"
@ -15,6 +16,8 @@ import (
_ "github.com/zitadel/zitadel/internal/api/ui/login/statik"
auth_repository "github.com/zitadel/zitadel/internal/auth/repository"
"github.com/zitadel/zitadel/internal/auth/repository/eventsourcing"
"github.com/zitadel/zitadel/internal/cache"
"github.com/zitadel/zitadel/internal/cache/connector"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
@ -38,6 +41,7 @@ type Login struct {
samlAuthCallbackURL func(context.Context, string) string
idpConfigAlg crypto.EncryptionAlgorithm
userCodeAlg crypto.EncryptionAlgorithm
caches *Caches
}
type Config struct {
@ -74,6 +78,7 @@ func CreateLogin(config Config,
userCodeAlg crypto.EncryptionAlgorithm,
idpConfigAlg crypto.EncryptionAlgorithm,
csrfCookieKey []byte,
cacheConnectors connector.Connectors,
) (*Login, error) {
login := &Login{
oidcAuthCallbackURL: oidcAuthCallbackURL,
@ -94,6 +99,12 @@ func CreateLogin(config Config,
login.router = CreateRouter(login, middleware.TelemetryHandler(IgnoreInstanceEndpoints...), oidcInstanceHandler, samlInstanceHandler, csrfInterceptor, cacheInterceptor, security, userAgentCookie, issuerInterceptor, accessHandler)
login.renderer = CreateRenderer(HandlerPrefix, staticStorage, config.LanguageCookieName)
login.parser = form.NewParser()
var err error
login.caches, err = startCaches(context.Background(), cacheConnectors)
if err != nil {
return nil, err
}
return login, nil
}
@ -201,3 +212,41 @@ func setUserContext(ctx context.Context, userID, resourceOwner string) context.C
func (l *Login) baseURL(ctx context.Context) string {
return http_utils.DomainContext(ctx).Origin() + HandlerPrefix
}
type Caches struct {
idpFormCallbacks cache.Cache[idpFormCallbackIndex, string, *idpFormCallback]
}
func startCaches(background context.Context, connectors connector.Connectors) (_ *Caches, err error) {
caches := new(Caches)
caches.idpFormCallbacks, err = connector.StartCache[idpFormCallbackIndex, string, *idpFormCallback](background, []idpFormCallbackIndex{idpFormCallbackIndexRequestID}, cache.PurposeIdPFormCallback, connectors.Config.IdPFormCallbacks, connectors)
if err != nil {
return nil, err
}
return caches, nil
}
type idpFormCallbackIndex int
const (
idpFormCallbackIndexUnspecified idpFormCallbackIndex = iota
idpFormCallbackIndexRequestID
)
type idpFormCallback struct {
InstanceID string
State string
Form url.Values
}
// Keys implements cache.Entry
func (c *idpFormCallback) Keys(i idpFormCallbackIndex) []string {
if i == idpFormCallbackIndexRequestID {
return []string{idpFormCallbackKey(c.InstanceID, c.State)}
}
return nil
}
func idpFormCallbackKey(instanceID, state string) string {
return instanceID + "-" + state
}

View File

@ -108,7 +108,7 @@ InitMFAPrompt:
InitMFAOTP:
Title: Zwei-Faktor-Authentifizierung
Description: Erstelle deinen Zweitfaktor. Installiere eine Authentifizierungs-App, wenn du noch keine hast.
OTPDescription: Scanne den Code mit einer Authentifizierungs-App (z.B. Google/Mircorsoft Authenticator, Authy) oder kopiere das Secret und gib anschliessend den Code ein.
OTPDescription: Scanne den Code mit einer Authentifizierungs-App (z.B. Google/Microsoft Authenticator, Authy) oder kopiere das Secret und gib anschliessend den Code ein.
SecretLabel: Secret
CodeLabel: Code
NextButtonText: Weiter

View File

@ -17,6 +17,7 @@ const (
PurposeAuthzInstance
PurposeMilestones
PurposeOrganization
PurposeIdPFormCallback
)
// Cache stores objects with a value of type `V`.

View File

@ -19,9 +19,10 @@ type CachesConfig struct {
Postgres pg.Config
Redis redis.Config
}
Instance *cache.Config
Milestones *cache.Config
Organization *cache.Config
Instance *cache.Config
Milestones *cache.Config
Organization *cache.Config
IdPFormCallbacks *cache.Config
}
type Connectors struct {

View File

@ -13,8 +13,8 @@ end
-- max-age must be checked manually
local expiry = getCall("HGET", object_id, "expiry")
if not (expiry == nil) and expiry > 0 then
if getTime() > expiry then
if not (expiry == nil) and tonumber(expiry) > 0 then
if getTime() > tonumber(expiry) then
remove(object_id)
return nil
end

View File

@ -7,11 +7,11 @@ import (
"strings"
)
const _PurposeName = "unspecifiedauthz_instancemilestonesorganization"
const _PurposeName = "unspecifiedauthz_instancemilestonesorganizationid_p_form_callback"
var _PurposeIndex = [...]uint8{0, 11, 25, 35, 47}
var _PurposeIndex = [...]uint8{0, 11, 25, 35, 47, 65}
const _PurposeLowerName = "unspecifiedauthz_instancemilestonesorganization"
const _PurposeLowerName = "unspecifiedauthz_instancemilestonesorganizationid_p_form_callback"
func (i Purpose) String() string {
if i < 0 || i >= Purpose(len(_PurposeIndex)-1) {
@ -28,9 +28,10 @@ func _PurposeNoOp() {
_ = x[PurposeAuthzInstance-(1)]
_ = x[PurposeMilestones-(2)]
_ = x[PurposeOrganization-(3)]
_ = x[PurposeIdPFormCallback-(4)]
}
var _PurposeValues = []Purpose{PurposeUnspecified, PurposeAuthzInstance, PurposeMilestones, PurposeOrganization}
var _PurposeValues = []Purpose{PurposeUnspecified, PurposeAuthzInstance, PurposeMilestones, PurposeOrganization, PurposeIdPFormCallback}
var _PurposeNameToValueMap = map[string]Purpose{
_PurposeName[0:11]: PurposeUnspecified,
@ -41,6 +42,8 @@ var _PurposeNameToValueMap = map[string]Purpose{
_PurposeLowerName[25:35]: PurposeMilestones,
_PurposeName[35:47]: PurposeOrganization,
_PurposeLowerName[35:47]: PurposeOrganization,
_PurposeName[47:65]: PurposeIdPFormCallback,
_PurposeLowerName[47:65]: PurposeIdPFormCallback,
}
var _PurposeNames = []string{
@ -48,6 +51,7 @@ var _PurposeNames = []string{
_PurposeName[11:25],
_PurposeName[25:35],
_PurposeName[35:47],
_PurposeName[47:65],
}
// PurposeString retrieves an enum value from the enum constants string name.

View File

@ -327,7 +327,7 @@ func (i *Instance) CreateUserIDPlink(ctx context.Context, userID, externalID, id
)
}
func (i *Instance) RegisterUserPasskey(ctx context.Context, userID string) {
func (i *Instance) RegisterUserPasskey(ctx context.Context, userID string) string {
reg, err := i.Client.UserV2.CreatePasskeyRegistrationLink(ctx, &user_v2.CreatePasskeyRegistrationLinkRequest{
UserId: userID,
Medium: &user_v2.CreatePasskeyRegistrationLinkRequest_ReturnCode{},
@ -350,9 +350,10 @@ func (i *Instance) RegisterUserPasskey(ctx context.Context, userID string) {
PasskeyName: "nice name",
})
logging.OnError(err).Panic("create user passkey")
return pkr.GetPasskeyId()
}
func (i *Instance) RegisterUserU2F(ctx context.Context, userID string) {
func (i *Instance) RegisterUserU2F(ctx context.Context, userID string) string {
pkr, err := i.Client.UserV2.RegisterU2F(ctx, &user_v2.RegisterU2FRequest{
UserId: userID,
Domain: i.Domain,
@ -368,6 +369,21 @@ func (i *Instance) RegisterUserU2F(ctx context.Context, userID string) {
TokenName: "nice name",
})
logging.OnError(err).Panic("create user u2f")
return pkr.GetU2FId()
}
func (i *Instance) RegisterUserOTPSMS(ctx context.Context, userID string) {
_, err := i.Client.UserV2.AddOTPSMS(ctx, &user_v2.AddOTPSMSRequest{
UserId: userID,
})
logging.OnError(err).Panic("create user sms")
}
func (i *Instance) RegisterUserOTPEmail(ctx context.Context, userID string) {
_, err := i.Client.UserV2.AddOTPEmail(ctx, &user_v2.AddOTPEmailRequest{
UserId: userID,
})
logging.OnError(err).Panic("create user email")
}
func (i *Instance) SetUserPassword(ctx context.Context, userID, password string, changeRequired bool) *object.Details {

View File

@ -270,6 +270,14 @@ func NewUserAuthMethodTypesSearchQuery(values ...domain.UserAuthMethodType) (Sea
return NewListQuery(UserAuthMethodColumnMethodType, list, ListIn)
}
func NewUserAuthMethodStatesSearchQuery(values ...domain.MFAState) (SearchQuery, error) {
list := make([]interface{}, len(values))
for i, value := range values {
list[i] = value
}
return NewListQuery(UserAuthMethodColumnState, list, ListIn)
}
func (r *UserAuthMethodSearchQueries) AppendResourceOwnerQuery(orgID string) error {
query, err := NewUserAuthMethodResourceOwnerSearchQuery(orgID)
if err != nil {
@ -306,6 +314,15 @@ func (r *UserAuthMethodSearchQueries) AppendStateQuery(state domain.MFAState) er
return nil
}
func (r *UserAuthMethodSearchQueries) AppendStatesQuery(state ...domain.MFAState) error {
query, err := NewUserAuthMethodStatesSearchQuery(state...)
if err != nil {
return err
}
r.Queries = append(r.Queries, query)
return nil
}
func (r *UserAuthMethodSearchQueries) AppendAuthMethodQuery(authMethod domain.UserAuthMethodType) error {
query, err := NewUserAuthMethodTypeSearchQuery(authMethod)
if err != nil {

View File

@ -276,6 +276,36 @@ message Passkey {
];
}
message AuthFactor {
AuthFactorState state = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "current state of the auth factor";
}
];
oneof type {
AuthFactorOTP otp = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "TOTP second factor"
}
];
AuthFactorU2F u2f = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "U2F second factor"
}
];
AuthFactorOTPSMS otp_sms = 4 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "SMS second factor"
}
];
AuthFactorOTPEmail otp_email = 5 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "Email second factor"
}
];
}
}
enum AuthFactorState {
AUTH_FACTOR_STATE_UNSPECIFIED = 0;
AUTH_FACTOR_STATE_NOT_READY = 1;
@ -283,6 +313,23 @@ enum AuthFactorState {
AUTH_FACTOR_STATE_REMOVED = 3;
}
message AuthFactorOTP {}
message AuthFactorOTPSMS {}
message AuthFactorOTPEmail {}
message AuthFactorU2F {
string id = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"69629023906488334\""
}
];
string name = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"fido key\""
}
];
}
message SendInviteCode {
// Optionally set a url_template, which will be used in the invite mail sent by ZITADEL to guide the user to your invitation page.
// If no template is set, the default ZITADEL url will be used.

View File

@ -1110,6 +1110,28 @@ service UserService {
};
}
rpc ListAuthenticationFactors(ListAuthenticationFactorsRequest) returns (ListAuthenticationFactorsResponse) {
option (google.api.http) = {
post: "/v2/users/{user_id}/authentication_factors/_search"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
responses: {
key: "200"
value: {
description: "OK";
}
};
};
}
// Create an invite code for a user
//
// Create an invite code for a user to initialize their first authentication method (password, passkeys, IdP) depending on the organization's available methods.
@ -2216,6 +2238,41 @@ enum AuthenticationMethodType {
AUTHENTICATION_METHOD_TYPE_OTP_EMAIL = 7;
}
message ListAuthenticationFactorsRequest{
string user_id = 1 [
(validate.rules).string = {min_len: 1, max_len: 200},
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 200;
example: "\"69629026806489455\"";
}
];
repeated AuthFactors auth_factors = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "Specify the Auth Factors you are interested in"
default: "All Auth Factors"
}
];
repeated AuthFactorState states = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "Specify the state of the Auth Factors"
default: "Auth Factors that are ready"
}
];
}
enum AuthFactors {
OTP = 0;
OTP_SMS = 1;
OTP_EMAIL = 2;
U2F = 3;
}
message ListAuthenticationFactorsResponse {
repeated zitadel.user.v2.AuthFactor result = 1;
}
message CreateInviteCodeRequest {
string user_id = 1 [
(validate.rules).string = {min_len: 1, max_len: 200},