feat(relational): implement session repository (#11109)

# Which Problems Are Solved

As part of going to a relation storage model, we need to implement the
repository for handling sessions and their events.

# How the Problems Are Solved

- Adds a migration step to create the necessary tables and types in the
database.
- Adds the repository implementation for sessions.
- Implements a projection handler for session events to handle the
events written by the old command handlers.
- Updates the `Session` type and `SessionRepository` interface:
  - added `CreatorID`
  - Adjusted `Delete` method to return amount of affected columns
  - Removed sub-repository clumns from the main interface
- Added `CreatorIDCondition`, `ExpirationCondition`, `ExistsFactor` and
`ExistsMetadata`
  - Removed `SetUserAgent`
- Methods of `SessionFactor` and `SessionChallenge` have been made
public
- fixed enumer on `SessionFactorType`
- Adds a `JSON` type to the repository package to easy can JSON columns,
resp. jsonb object inside a property of a `JSONArray` object.
- Adds a `NoChange` interface, which can be implemented to signal that a
`Change` interface implementation is not writing any changes to the
statement builder, e.g. `CTEChange`

# Additional Changes

- Fixes the operators enums. `TextOperations` had wrong comments and
`NumberOperations` additionally also had from comparisons.
- Fixes downgrade of migration step 008.

# Additional Context

- closes https://github.com/zitadel/zitadel/issues/10212
This commit is contained in:
Livio Spring
2026-01-06 08:08:11 +01:00
committed by GitHub
parent 5f34d1af8f
commit 4e6f68c511
25 changed files with 4305 additions and 387 deletions
+41 -6
View File
@@ -17,6 +17,12 @@ type Change interface {
IsOnColumn(col Column) bool
}
// NoChange can be implemented by Changes that might not result in any actual change,
// resp. when the change has no set statement, e.g. [CTEChange] with no change function.
type NoChange interface {
NoChange() bool
}
type change[V Value] struct {
column Column
value V
@@ -88,15 +94,23 @@ func (c Changes) IsOnColumn(col Column) bool {
}
// Write implements [Change].
func (m Changes) Write(builder *StatementBuilder) error {
for i, change := range m {
if i > 0 {
func (c Changes) Write(builder *StatementBuilder) error {
var hadChanges bool
for _, change := range c {
ch, ok := change.(NoChange)
hasChanges := !ok || !ch.NoChange()
// if the previous change actually wrote a change to the builder
// and this change has changes, add a comma
if hadChanges && hasChanges {
builder.WriteString(", ")
}
err := change.Write(builder)
if err != nil {
if err := change.Write(builder); err != nil {
return err
}
// if the change had changes, mark that we had changes for the next iteration
if hasChanges {
hadChanges = hasChanges
}
}
return nil
}
@@ -123,6 +137,21 @@ func (c Changes) String() string {
return "database.Changes"
}
// NoChange implements [NoChange].
// It returns false if any of the changes writes to the builder.
func (c Changes) NoChange() bool {
for _, change := range c {
no, ok := change.(NoChange)
if !ok {
return false
}
if no.NoChange() {
return true
}
}
return true
}
var _ Change = Changes(nil)
func NewChangeToNull(col Column) Change {
@@ -319,7 +348,7 @@ func (c *cteChange) Matches(x any) bool {
return slices.Equal(expectedCTEBuilder.Args(), actualCTEBuilder.Args())
}
// Name implements [CTEChange].
// SetName implements [CTEChange].
func (c *cteChange) SetName(name string) {
c.name = name
}
@@ -342,3 +371,9 @@ func (c *cteChange) Write(builder *StatementBuilder) error {
func (c *cteChange) WriteCTE(builder *StatementBuilder) {
c.cte(builder)
}
// NoChange implements [NoChange].
// It returns true if there is no change function defined.
func (c *cteChange) NoChange() bool {
return c.change == nil
}
@@ -1,3 +1,3 @@
DROP TYPE zitadel.project_grant_state;
DROP TYPE IF EXISTS zitadel.project_grant_state CASCADE ;
DROP TABLE IF EXISTS zitadel.project_grant_roles;
DROP TABLE IF EXISTS zitadel.project_grants;
@@ -0,0 +1,16 @@
package migration
import (
_ "embed"
)
var (
//go:embed 011_session_table/up.sql
up011SessionTable string
//go:embed 011_session_table/down.sql
down011SessionTable string
)
func init() {
registerSQLMigration(11, up011SessionTable, down011SessionTable)
}
@@ -0,0 +1,6 @@
DROP TABLE IF EXISTS zitadel.session_metadata CASCADE;
DROP TABLE IF EXISTS zitadel.session_user_agents CASCADE;
DROP TYPE IF EXISTS zitadel.session_factor_type CASCADE;
DROP TABLE IF EXISTS zitadel.session_factors CASCADE;
DROP TABLE IF EXISTS zitadel.sessions CASCADE;
DROP FUNCTION IF EXISTS zitadel.update_expiration();
@@ -0,0 +1,93 @@
CREATE TABLE zitadel.session_user_agents (
instance_id TEXT NOT NULL
, fingerprint_id TEXT NOT NULL CHECK (fingerprint_id <> '')
, ip INET
, description TEXT
, headers JSONB
, PRIMARY KEY (instance_id, fingerprint_id)
);
CREATE TABLE zitadel.sessions (
instance_id TEXT NOT NULL
, id TEXT NOT NULL CHECK (id <> '')
, token_id TEXT
, user_agent_id TEXT
, lifetime INTERVAL
, expiration TIMESTAMPTZ
, user_id TEXT -- this column is used for referential integrity
, creator_id TEXT
, created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
, updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
, PRIMARY KEY (instance_id, id)
, FOREIGN KEY (instance_id) REFERENCES zitadel.instances(id)
-- , FOREIGN KEY (instance_id, user_id) REFERENCES zitadel.users(instance_id, id) ON DELETE CASCADE
, FOREIGN KEY (instance_id, user_agent_id) REFERENCES zitadel.session_user_agents(instance_id, fingerprint_id) ON DELETE SET NULL (user_agent_id)
);
CREATE OR REPLACE FUNCTION zitadel.update_expiration()
RETURNS TRIGGER AS $$
BEGIN
NEW.expiration := NEW.updated_at + NEW.lifetime;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER set_expiration
BEFORE INSERT OR UPDATE OF lifetime ON zitadel.sessions
FOR EACH ROW
EXECUTE FUNCTION zitadel.update_expiration();
CREATE TYPE zitadel.session_factor_type AS ENUM (
'user',
'password',
'passkey', -- is also a challenge
'identity_provider_intent',
'totp',
'otp_sms', -- is also a challenge
'otp_email', -- is also a challenge
'recovery_code'
);
CREATE TABLE zitadel.session_factors (
instance_id TEXT NOT NULL
, session_id TEXT NOT NULL
, type zitadel.session_factor_type NOT NULL
, last_challenged_at TIMESTAMPTZ -- this is only set if the type is a challenge
, challenged_payload JSONB
, last_verified_at TIMESTAMPTZ
, verified_payload JSONB
, PRIMARY KEY (instance_id, session_id, type)
, FOREIGN KEY (instance_id, session_id) REFERENCES zitadel.sessions(instance_id, id) ON DELETE CASCADE
);
CREATE TABLE zitadel.session_metadata (
instance_id TEXT NOT NULL
, session_id TEXT NOT NULL
, key TEXT NOT NULL CHECK (key <> '')
, value BYTEA NOT NULL
, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
, PRIMARY KEY (instance_id, session_id, key)
, CONSTRAINT fk_session_metadata_session FOREIGN KEY (instance_id, session_id) REFERENCES zitadel.sessions (instance_id, id) ON DELETE CASCADE
);
CREATE INDEX idx_session_metadata_key ON zitadel.session_metadata (key);
CREATE INDEX idx_session_metadata_value ON zitadel.session_metadata (sha256(value));
-- TODO(adlerhurst): these indexes can currently not be used by Postgres, because of the type conversion
-- the value can be a json but doesn't have to be.
-- CREATE INDEX idx_session_metadata_value_number ON zitadel.session_metadata ((value::NUMERIC)) WHERE jsonb_typeof(value) = 'number';
-- CREATE INDEX idx_session_metadata_value_string ON zitadel.session_metadata ((value#>>'{}')) WHERE jsonb_typeof(value) = 'string';
-- CREATE INDEX idx_session_metadata_value_boolean ON zitadel.session_metadata ((value::BOOLEAN)) WHERE jsonb_typeof(value) = 'boolean';
CREATE TRIGGER trg_set_updated_at_session_metadata
BEFORE INSERT OR UPDATE ON zitadel.session_metadata
FOR EACH ROW
WHEN (NEW.updated_at IS NULL)
EXECUTE FUNCTION zitadel.set_updated_at();
@@ -22,8 +22,9 @@ import (
mgmt "github.com/zitadel/zitadel/pkg/grpc/management"
v2beta_org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta"
v2beta_project "github.com/zitadel/zitadel/pkg/grpc/project/v2beta"
"github.com/zitadel/zitadel/pkg/grpc/session/v2"
"github.com/zitadel/zitadel/pkg/grpc/system"
user_v2 "github.com/zitadel/zitadel/pkg/grpc/user/v2"
"github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
func getEnv(key, fallback string) string {
@@ -51,9 +52,10 @@ var (
SystemClient system.SystemServiceClient
OrgClient v2beta_org.OrganizationServiceClient
ProjectClient v2beta_project.ProjectServiceClient
SessionClient session.SessionServiceClient
UserClient user.UserServiceClient
AdminClient admin.AdminServiceClient
MgmtClient mgmt.ManagementServiceClient
UserClient user_v2.UserServiceClient
AuthorizationClient authorization.AuthorizationServiceClient
)
@@ -71,9 +73,10 @@ func TestMain(m *testing.M) {
SystemClient = integration.SystemClient()
OrgClient = Instance.Client.OrgV2beta
ProjectClient = Instance.Client.Projectv2Beta
SessionClient = Instance.Client.SessionV2
UserClient = Instance.Client.UserV2
AdminClient = Instance.Client.Admin
MgmtClient = Instance.Client.Mgmt
UserClient = Instance.Client.UserV2
AuthorizationClient = Instance.Client.AuthorizationV2
defer func() {
@@ -0,0 +1,626 @@
//go:build integration
package events_test
import (
"bytes"
"context"
"net"
"net/http"
"net/url"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/durationpb"
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database"
"github.com/zitadel/zitadel/backend/v3/storage/database/repository"
"github.com/zitadel/zitadel/internal/crypto"
zdomain "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/internal/integration/sink"
"github.com/zitadel/zitadel/pkg/grpc/session/v2"
"github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
func TestServer_SessionReduces(t *testing.T) {
instanceID := Instance.ID()
sessionRepo := repository.SessionRepository()
email := integration.Email()
testUser := Instance.CreateUserTypeHuman(CTX, email)
_, err := UserClient.VerifyEmail(CTX, &user.VerifyEmailRequest{
UserId: testUser.GetId(),
VerificationCode: testUser.GetEmailCode(),
})
require.NoError(t, err)
_, err = UserClient.SetPassword(CTX, &user.SetPasswordRequest{
UserId: testUser.GetId(),
NewPassword: &user.Password{
Password: integration.UserPassword,
},
Verification: nil,
})
require.NoError(t, err)
totpSecret := registerTOTP(CTX, t, testUser.GetId())
Instance.RegisterUserPasskey(CTX, testUser.GetId())
registerOTPEmail(CTX, t, testUser.GetId())
_, err = UserClient.SetPhone(CTX, &user.SetPhoneRequest{
UserId: testUser.GetId(),
Phone: integration.Phone(),
Verification: &user.SetPhoneRequest_IsVerified{
IsVerified: true,
},
})
require.NoError(t, err)
registerOTPSMS(CTX, t, testUser.GetId())
idpID := Instance.AddGenericOAuthProvider(CTX, integration.IDPName()).GetId()
idpUserID := integration.ID()
_, err = UserClient.AddIDPLink(CTX, &user.AddIDPLinkRequest{
UserId: testUser.GetId(),
IdpLink: &user.IDPLink{
IdpId: idpID,
UserId: idpUserID,
UserName: integration.Username(),
},
})
require.NoError(t, err)
recoveryCodes := createRecoveryCodes(CTX, t, testUser.GetId())
lifetime := time.Hour
creatorID := "tester"
userAgent := &domain.SessionUserAgent{
FingerprintID: gu.Ptr(integration.ID()),
Description: gu.Ptr("description"),
IP: net.IPv4(127, 0, 0, 1),
Header: http.Header{
"User-Agent": []string{"ZITADEL-Integration-Test"},
},
}
createdSession, err := SessionClient.CreateSession(CTX, &session.CreateSessionRequest{
Checks: nil,
Metadata: nil,
Challenges: nil,
UserAgent: &session.UserAgent{
FingerprintId: userAgent.FingerprintID,
Ip: gu.Ptr(userAgent.IP.String()),
Description: userAgent.Description,
Header: map[string]*session.UserAgent_HeaderValues{
"User-Agent": {Values: []string{"ZITADEL-Integration-Test"}},
},
},
Lifetime: durationpb.New(lifetime),
})
require.NoError(t, err)
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, 10*time.Second)
var updatedSession *session.SetSessionResponse
t.Run("create session reduces", func(t *testing.T) {
require.EventuallyWithT(t, func(collect *assert.CollectT) {
dbSession, err := sessionRepo.Get(CTX, pool, database.WithCondition(
sessionRepo.PrimaryKeyCondition(instanceID, createdSession.GetSessionId()),
))
require.NoError(collect, err)
assertSessionsEqual(collect, &domain.Session{
InstanceID: instanceID,
ID: createdSession.GetSessionId(),
TokenID: createdSession.GetSessionToken(),
Lifetime: lifetime,
Expiration: createdSession.GetDetails().GetChangeDate().AsTime().Add(lifetime),
UserID: "",
CreatorID: creatorID,
CreatedAt: createdSession.GetDetails().GetChangeDate().AsTime(),
UpdatedAt: createdSession.GetDetails().GetChangeDate().AsTime(),
Factors: nil,
Challenges: nil,
Metadata: nil,
UserAgent: userAgent,
}, dbSession)
}, retryDuration, tick, "session not found within %v: %v", retryDuration, err)
})
metadata := []*domain.SessionMetadata{
{
Metadata: domain.Metadata{
InstanceID: instanceID,
Key: "key1",
Value: []byte("value1"),
},
SessionID: createdSession.GetSessionId(),
},
}
t.Run("metadata set reduces", func(t *testing.T) {
updatedSession, err = SessionClient.SetSession(CTX, &session.SetSessionRequest{
SessionId: createdSession.GetSessionId(),
Checks: nil,
Metadata: map[string][]byte{"key1": []byte("value1")},
})
require.NoError(t, err)
require.EventuallyWithT(t, func(collect *assert.CollectT) {
dbSession, err := sessionRepo.Get(CTX, pool, database.WithCondition(
sessionRepo.PrimaryKeyCondition(instanceID, createdSession.GetSessionId()),
))
require.NoError(collect, err)
assertSessionsEqual(collect, &domain.Session{
InstanceID: instanceID,
ID: createdSession.GetSessionId(),
TokenID: createdSession.GetSessionToken(),
Lifetime: lifetime,
Expiration: createdSession.GetDetails().GetChangeDate().AsTime().Add(lifetime),
UserID: "",
CreatorID: creatorID,
CreatedAt: createdSession.GetDetails().GetChangeDate().AsTime(),
UpdatedAt: updatedSession.GetDetails().GetChangeDate().AsTime(),
Factors: nil,
Challenges: nil,
Metadata: metadata,
UserAgent: userAgent,
}, dbSession)
}, retryDuration, tick, "session not found within %v: %v", retryDuration, err)
})
var lifetimeUpdated time.Time
t.Run("lifetime set reduces", func(t *testing.T) {
updatedSession, err = SessionClient.SetSession(CTX, &session.SetSessionRequest{
SessionId: createdSession.GetSessionId(),
Lifetime: durationpb.New(lifetime),
})
require.NoError(t, err)
lifetimeUpdated = updatedSession.GetDetails().GetChangeDate().AsTime()
require.EventuallyWithT(t, func(collect *assert.CollectT) {
dbSession, err := sessionRepo.Get(CTX, pool, database.WithCondition(
sessionRepo.PrimaryKeyCondition(instanceID, createdSession.GetSessionId()),
))
require.NoError(collect, err)
assertSessionsEqual(collect, &domain.Session{
InstanceID: instanceID,
ID: createdSession.GetSessionId(),
TokenID: createdSession.GetSessionToken(),
Lifetime: lifetime,
Expiration: lifetimeUpdated.Add(lifetime),
UserID: "",
CreatorID: creatorID,
CreatedAt: createdSession.GetDetails().GetChangeDate().AsTime(),
UpdatedAt: updatedSession.GetDetails().GetChangeDate().AsTime(),
Factors: nil,
Challenges: nil,
Metadata: metadata,
UserAgent: userAgent,
}, dbSession)
}, retryDuration, tick, "session not found within %v: %v", retryDuration, err)
})
var firstFactorCheckTime time.Time
t.Run("checks set reduces", func(t *testing.T) {
_, err := url.Parse(Instance.CreateIntent(CTX, idpID).GetAuthUrl())
require.NoError(t, err)
expiry := time.Now().Add(1 * time.Hour)
intentID, intentToken, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, idpUserID, testUser.GetId(), expiry)
require.NoError(t, err)
updatedSession, err = SessionClient.SetSession(CTX, &session.SetSessionRequest{
SessionId: createdSession.GetSessionId(),
Checks: &session.Checks{
User: &session.CheckUser{
Search: &session.CheckUser_LoginName{
LoginName: email,
},
},
Password: &session.CheckPassword{
Password: integration.UserPassword,
},
IdpIntent: &session.CheckIDPIntent{
IdpIntentId: intentID,
IdpIntentToken: intentToken,
},
Totp: &session.CheckTOTP{
Code: func() string {
code, err := totp.GenerateCode(totpSecret, time.Now())
require.NoError(t, err)
return code
}(),
},
RecoveryCode: &session.CheckRecoveryCode{
Code: recoveryCodes[0],
},
},
Challenges: &session.RequestChallenges{
WebAuthN: &session.RequestChallenges_WebAuthN{
Domain: Instance.Domain,
UserVerificationRequirement: session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED,
},
OtpEmail: &session.RequestChallenges_OTPEmail{
DeliveryType: &session.RequestChallenges_OTPEmail_ReturnCode_{
ReturnCode: &session.RequestChallenges_OTPEmail_ReturnCode{},
},
},
OtpSms: &session.RequestChallenges_OTPSMS{
ReturnCode: true,
},
},
})
require.NoError(t, err)
firstFactorCheckTime = updatedSession.GetDetails().GetChangeDate().AsTime()
require.EventuallyWithT(t, func(collect *assert.CollectT) {
dbSession, err := sessionRepo.Get(CTX, pool, database.WithCondition(
sessionRepo.PrimaryKeyCondition(instanceID, createdSession.GetSessionId()),
))
require.NoError(collect, err)
assertSessionsEqual(collect, &domain.Session{
InstanceID: instanceID,
ID: createdSession.GetSessionId(),
TokenID: createdSession.GetSessionToken(),
Lifetime: lifetime,
Expiration: lifetimeUpdated.Add(lifetime),
UserID: testUser.GetId(),
CreatorID: creatorID,
CreatedAt: createdSession.GetDetails().GetChangeDate().AsTime(),
UpdatedAt: updatedSession.GetDetails().GetChangeDate().AsTime(),
Factors: domain.SessionFactors{
&domain.SessionFactorUser{
UserID: testUser.GetId(),
LastVerifiedAt: firstFactorCheckTime,
},
&domain.SessionFactorPassword{
LastVerifiedAt: firstFactorCheckTime,
},
&domain.SessionFactorIdentityProviderIntent{
LastVerifiedAt: firstFactorCheckTime,
},
&domain.SessionFactorTOTP{
LastVerifiedAt: firstFactorCheckTime,
},
&domain.SessionFactorRecoveryCode{
LastVerifiedAt: firstFactorCheckTime,
},
},
Challenges: domain.SessionChallenges{
&domain.SessionChallengePasskey{
LastChallengedAt: updatedSession.GetDetails().GetChangeDate().AsTime(),
Challenge: "challenge", // placeholder
AllowedCredentialIDs: [][]byte{Instance.WebAuthN.KeyID()},
UserVerification: zdomain.UserVerificationRequirementRequired,
RPID: Instance.Domain,
},
&domain.SessionChallengeOTPEmail{
LastChallengedAt: updatedSession.GetDetails().GetChangeDate().AsTime(),
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "",
KeyID: "",
Crypted: nil,
},
Expiry: 0,
CodeReturned: true,
URLTemplate: "",
TriggeredAtOrigin: "",
},
&domain.SessionChallengeOTPSMS{
LastChallengedAt: updatedSession.GetDetails().GetChangeDate().AsTime(),
Code: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "",
KeyID: "",
Crypted: nil,
},
Expiry: 0,
CodeReturned: true,
GeneratorID: "",
TriggeredAtOrigin: "",
},
},
Metadata: metadata,
UserAgent: userAgent,
}, dbSession)
}, retryDuration, tick, "session not found within %v: %v", retryDuration, err)
})
t.Run("checks with challenges set reduces", func(t *testing.T) {
assertionData, err := Instance.WebAuthN.CreateAssertionResponse(updatedSession.GetChallenges().GetWebAuthN().GetPublicKeyCredentialRequestOptions(), true)
require.NoError(t, err)
updatedSession, err = SessionClient.SetSession(CTX, &session.SetSessionRequest{
SessionId: createdSession.GetSessionId(),
Checks: &session.Checks{
WebAuthN: &session.CheckWebAuthN{
CredentialAssertionData: assertionData,
},
OtpEmail: &session.CheckOTP{
Code: updatedSession.GetChallenges().GetOtpEmail(),
},
OtpSms: &session.CheckOTP{
Code: updatedSession.GetChallenges().GetOtpSms(),
},
},
})
require.NoError(t, err)
require.EventuallyWithT(t, func(collect *assert.CollectT) {
dbSession, err := sessionRepo.Get(CTX, pool, database.WithCondition(
sessionRepo.PrimaryKeyCondition(instanceID, createdSession.GetSessionId()),
))
require.NoError(collect, err)
assertSessionsEqual(collect, &domain.Session{
InstanceID: instanceID,
ID: createdSession.GetSessionId(),
TokenID: createdSession.GetSessionToken(),
Lifetime: lifetime,
Expiration: lifetimeUpdated.Add(lifetime),
UserID: testUser.GetId(),
CreatorID: creatorID,
CreatedAt: createdSession.GetDetails().GetChangeDate().AsTime(),
UpdatedAt: updatedSession.GetDetails().GetChangeDate().AsTime(),
Factors: domain.SessionFactors{
&domain.SessionFactorUser{
UserID: testUser.GetId(),
LastVerifiedAt: firstFactorCheckTime,
},
&domain.SessionFactorPassword{
LastVerifiedAt: firstFactorCheckTime,
},
&domain.SessionFactorIdentityProviderIntent{
LastVerifiedAt: firstFactorCheckTime,
},
&domain.SessionFactorPasskey{
LastVerifiedAt: updatedSession.GetDetails().GetChangeDate().AsTime(),
UserVerified: true,
},
&domain.SessionFactorTOTP{
LastVerifiedAt: firstFactorCheckTime,
},
&domain.SessionFactorOTPEmail{
LastVerifiedAt: updatedSession.GetDetails().GetChangeDate().AsTime(),
},
&domain.SessionFactorOTPSMS{
LastVerifiedAt: updatedSession.GetDetails().GetChangeDate().AsTime(),
},
&domain.SessionFactorRecoveryCode{
LastVerifiedAt: firstFactorCheckTime,
},
},
Challenges: domain.SessionChallenges{},
Metadata: metadata,
UserAgent: userAgent,
}, dbSession)
}, retryDuration, tick, "session not found within %v: %v", retryDuration, err)
})
t.Run("password changed reduces", func(t *testing.T) {
passwordSet, err := UserClient.SetPassword(CTX, &user.SetPasswordRequest{
UserId: testUser.GetId(),
NewPassword: &user.Password{
Password: integration.UserPassword + integration.RandString(5),
},
Verification: &user.SetPasswordRequest_CurrentPassword{
CurrentPassword: integration.UserPassword,
},
})
require.NoError(t, err)
require.EventuallyWithT(t, func(collect *assert.CollectT) {
dbSession, err := sessionRepo.Get(CTX, pool, database.WithCondition(
sessionRepo.PrimaryKeyCondition(instanceID, createdSession.GetSessionId()),
))
require.NoError(collect, err)
assertSessionsEqual(collect, &domain.Session{
InstanceID: instanceID,
ID: createdSession.GetSessionId(),
TokenID: createdSession.GetSessionToken(),
Lifetime: lifetime,
Expiration: lifetimeUpdated.Add(lifetime),
UserID: testUser.GetId(),
CreatorID: creatorID,
CreatedAt: createdSession.GetDetails().GetChangeDate().AsTime(),
UpdatedAt: passwordSet.GetDetails().GetChangeDate().AsTime(),
Factors: domain.SessionFactors{
&domain.SessionFactorUser{
UserID: testUser.GetId(),
LastVerifiedAt: firstFactorCheckTime,
},
// password factor removed
&domain.SessionFactorIdentityProviderIntent{
LastVerifiedAt: firstFactorCheckTime,
},
&domain.SessionFactorPasskey{
LastVerifiedAt: updatedSession.GetDetails().GetChangeDate().AsTime(),
UserVerified: true,
},
&domain.SessionFactorTOTP{
LastVerifiedAt: firstFactorCheckTime,
},
&domain.SessionFactorOTPEmail{
LastVerifiedAt: updatedSession.GetDetails().GetChangeDate().AsTime(),
},
&domain.SessionFactorOTPSMS{
LastVerifiedAt: updatedSession.GetDetails().GetChangeDate().AsTime(),
},
&domain.SessionFactorRecoveryCode{
LastVerifiedAt: firstFactorCheckTime,
},
},
Challenges: domain.SessionChallenges{},
Metadata: metadata,
UserAgent: userAgent,
}, dbSession)
}, retryDuration, tick, "session not found within %v: %v", retryDuration, err)
})
t.Run("terminated reduces", func(t *testing.T) {
_, err := SessionClient.DeleteSession(CTX, &session.DeleteSessionRequest{
SessionId: createdSession.GetSessionId(),
SessionToken: gu.Ptr(updatedSession.GetSessionToken()),
})
require.NoError(t, err)
require.EventuallyWithT(t, func(collect *assert.CollectT) {
dbSession, err := sessionRepo.Get(CTX, pool, database.WithCondition(
sessionRepo.PrimaryKeyCondition(instanceID, createdSession.GetSessionId()),
))
require.ErrorIs(collect, err, new(database.NoRowFoundError))
assert.Nil(collect, dbSession)
}, retryDuration, tick, "session not found within %v: %v", retryDuration, err)
})
}
func assertSessionsEqual(t *assert.CollectT, expected, actual *domain.Session) {
t.Helper()
assert.Equal(t, expected.InstanceID, actual.InstanceID)
assert.Equal(t, expected.ID, actual.ID)
assert.NotNil(t, actual.TokenID)
assert.Equal(t, expected.Lifetime, actual.Lifetime)
assert.Equal(t, expected.Expiration.UTC(), actual.Expiration.UTC())
assert.Equal(t, expected.UserID, actual.UserID)
assert.Equal(t, expected.CreatorID, actual.CreatorID)
assert.NotNil(t, actual.CreatedAt)
assert.NotNil(t, actual.UpdatedAt)
assertSessionFactorsEqual(t, expected.Factors, actual.Factors)
assertSessionChallengesEqual(t, expected.Challenges, actual.Challenges)
assertSessionMetadataEqual(t, expected.Metadata, actual.Metadata)
assert.Equal(t, expected.UserAgent, actual.UserAgent)
}
func assertSessionMetadataEqual(t *assert.CollectT, expected, actual []*domain.SessionMetadata) {
t.Helper()
assert.Len(t, actual, len(expected))
for _, exp := range expected {
found := false
for _, act := range actual {
if exp.InstanceID == act.InstanceID &&
exp.SessionID == act.SessionID &&
exp.Key == act.Key &&
bytes.Equal(exp.Value, act.Value) {
found = true
break
}
}
assert.Truef(t, found, "expected metadata not found: %+v", exp)
}
}
func assertSessionFactorsEqual(t *assert.CollectT, expected, actual domain.SessionFactors) {
t.Helper()
assert.Len(t, actual, len(expected))
for _, exp := range expected {
found := false
for _, act := range actual {
if exp.SessionFactorType() == act.SessionFactorType() {
switch expTyped := exp.(type) {
case *domain.SessionFactorUser:
actTyped := act.(*domain.SessionFactorUser)
assert.Equal(t, expTyped.UserID, actTyped.UserID)
assert.Equal(t, expTyped.LastVerifiedAt.UTC(), actTyped.LastVerifiedAt.UTC())
case *domain.SessionFactorPassword:
actTyped := act.(*domain.SessionFactorPassword)
assert.Equal(t, expTyped.LastVerifiedAt.UTC(), actTyped.LastVerifiedAt.UTC())
case *domain.SessionFactorIdentityProviderIntent:
actTyped := act.(*domain.SessionFactorIdentityProviderIntent)
assert.Equal(t, expTyped.LastVerifiedAt.UTC(), actTyped.LastVerifiedAt.UTC())
case *domain.SessionFactorPasskey:
actTyped := act.(*domain.SessionFactorPasskey)
assert.Equal(t, expTyped.LastVerifiedAt.UTC(), actTyped.LastVerifiedAt.UTC())
assert.Equal(t, expTyped.UserVerified, actTyped.UserVerified)
case *domain.SessionFactorTOTP:
actTyped := act.(*domain.SessionFactorTOTP)
assert.Equal(t, expTyped.LastVerifiedAt.UTC(), actTyped.LastVerifiedAt.UTC())
case *domain.SessionFactorOTPEmail:
actTyped := act.(*domain.SessionFactorOTPEmail)
assert.Equal(t, expTyped.LastVerifiedAt.UTC(), actTyped.LastVerifiedAt.UTC())
case *domain.SessionFactorOTPSMS:
actTyped := act.(*domain.SessionFactorOTPSMS)
assert.Equal(t, expTyped.LastVerifiedAt.UTC(), actTyped.LastVerifiedAt.UTC())
case *domain.SessionFactorRecoveryCode:
actTyped := act.(*domain.SessionFactorRecoveryCode)
assert.Equal(t, expTyped.LastVerifiedAt.UTC(), actTyped.LastVerifiedAt.UTC())
}
found = true
break
}
}
assert.Truef(t, found, "expected factor not found: %+v", exp)
}
}
func assertSessionChallengesEqual(t *assert.CollectT, expected, actual domain.SessionChallenges) {
t.Helper()
assert.Len(t, actual, len(expected))
for _, exp := range expected {
found := false
for _, act := range actual {
if exp.SessionChallengeType() == act.SessionChallengeType() {
switch expTyped := exp.(type) {
case *domain.SessionChallengePasskey:
actTyped := act.(*domain.SessionChallengePasskey)
assert.Equal(t, expTyped.LastChallengedAt.UTC(), actTyped.LastChallengedAt.UTC())
assert.NotEmpty(t, actTyped.Challenge)
assert.Equal(t, expTyped.AllowedCredentialIDs, actTyped.AllowedCredentialIDs)
assert.Equal(t, expTyped.RPID, actTyped.RPID)
assert.Equal(t, expTyped.UserVerification, actTyped.UserVerification)
case *domain.SessionChallengeOTPEmail:
actTyped := act.(*domain.SessionChallengeOTPEmail)
assert.Equal(t, expTyped.LastChallengedAt.UTC(), actTyped.LastChallengedAt.UTC())
assert.Equal(t, expTyped.Code.CryptoType, actTyped.Code.CryptoType)
case *domain.SessionChallengeOTPSMS:
actTyped := act.(*domain.SessionChallengeOTPSMS)
assert.Equal(t, expTyped.LastChallengedAt.UTC(), actTyped.LastChallengedAt.UTC())
assert.Equal(t, expTyped.Code.CryptoType, actTyped.Code.CryptoType)
}
found = true
break
}
}
assert.Truef(t, found, "expected challenge not found: %+v", exp)
}
}
func registerTOTP(ctx context.Context, t *testing.T, userID string) (secret string) {
resp, err := UserClient.RegisterTOTP(ctx, &user.RegisterTOTPRequest{
UserId: userID,
})
require.NoError(t, err)
secret = resp.GetSecret()
code, err := totp.GenerateCode(secret, time.Now())
require.NoError(t, err)
_, err = UserClient.VerifyTOTPRegistration(ctx, &user.VerifyTOTPRegistrationRequest{
UserId: userID,
Code: code,
})
require.NoError(t, err)
return secret
}
func registerOTPSMS(ctx context.Context, t *testing.T, userID string) {
_, err := UserClient.AddOTPSMS(ctx, &user.AddOTPSMSRequest{
UserId: userID,
})
require.NoError(t, err)
}
func registerOTPEmail(ctx context.Context, t *testing.T, userID string) {
_, err := UserClient.AddOTPEmail(ctx, &user.AddOTPEmailRequest{
UserId: userID,
})
require.NoError(t, err)
}
func createRecoveryCodes(ctx context.Context, t *testing.T, userID string) (secret []string) {
resp, err := UserClient.GenerateRecoveryCodes(ctx, &user.GenerateRecoveryCodesRequest{
UserId: userID,
Count: 5,
})
require.NoError(t, err)
return resp.GetRecoveryCodes()
}
+4 -4
View File
@@ -60,9 +60,9 @@ const (
// TextOperationContainsIgnoreCase checks if the first string contains the second ignoring case
TextOperationContainsIgnoreCase // LIKE
// TextOperationEndsWith checks if the first string ends with the second ignoring case
// TextOperationEndsWithIgnoreCase checks if the first string ends with the second ignoring case
TextOperationEndsWithIgnoreCase // LIKE
// TextOperationStartsWith checks if the first string starts with the second ignoring case
// TextOperationStartsWithIgnoreCase checks if the first string starts with the second ignoring case
TextOperationStartsWithIgnoreCase // LIKE
// TextOperationEqualIgnoreCase compares two strings for equality ignoring case
TextOperationEqualIgnoreCase // =
@@ -133,11 +133,11 @@ const (
// NumberOperationLessThan compares two numbers to check if the first is less than the second.
NumberOperationLessThan // <
// NumberOperationLessThanOrEqual compares two numbers to check if the first is less than or equal to the second.
NumberOperationAtLeast // <=
NumberOperationLessThanOrEqual // <=
// NumberOperationGreaterThan compares two numbers to check if the first is greater than the second.
NumberOperationGreaterThan // >
// NumberOperationGreaterThanOrEqual compares two numbers to check if the first is greater than or equal to the second.
NumberOperationAtMost // >=
NumberOperationGreaterThanOrEqual // >=
)
func writeNumberOperation[T Number](builder *StatementBuilder, col Column, op NumberOperation, value any) {
@@ -28,12 +28,12 @@ func _NumberOperationNoOp() {
_ = x[NumberOperationEqual-(1)]
_ = x[NumberOperationNotEqual-(2)]
_ = x[NumberOperationLessThan-(3)]
_ = x[NumberOperationAtLeast-(4)]
_ = x[NumberOperationLessThanOrEqual-(4)]
_ = x[NumberOperationGreaterThan-(5)]
_ = x[NumberOperationAtMost-(6)]
_ = x[NumberOperationGreaterThanOrEqual-(6)]
}
var _NumberOperationValues = []NumberOperation{NumberOperationEqual, NumberOperationNotEqual, NumberOperationLessThan, NumberOperationAtLeast, NumberOperationGreaterThan, NumberOperationAtMost}
var _NumberOperationValues = []NumberOperation{NumberOperationEqual, NumberOperationNotEqual, NumberOperationLessThan, NumberOperationLessThanOrEqual, NumberOperationGreaterThan, NumberOperationGreaterThanOrEqual}
var _NumberOperationNameToValueMap = map[string]NumberOperation{
_NumberOperationName[0:1]: NumberOperationEqual,
@@ -42,12 +42,12 @@ var _NumberOperationNameToValueMap = map[string]NumberOperation{
_NumberOperationLowerName[1:3]: NumberOperationNotEqual,
_NumberOperationName[3:4]: NumberOperationLessThan,
_NumberOperationLowerName[3:4]: NumberOperationLessThan,
_NumberOperationName[4:6]: NumberOperationAtLeast,
_NumberOperationLowerName[4:6]: NumberOperationAtLeast,
_NumberOperationName[4:6]: NumberOperationLessThanOrEqual,
_NumberOperationLowerName[4:6]: NumberOperationLessThanOrEqual,
_NumberOperationName[6:7]: NumberOperationGreaterThan,
_NumberOperationLowerName[6:7]: NumberOperationGreaterThan,
_NumberOperationName[7:9]: NumberOperationAtMost,
_NumberOperationLowerName[7:9]: NumberOperationAtMost,
_NumberOperationName[7:9]: NumberOperationGreaterThanOrEqual,
_NumberOperationLowerName[7:9]: NumberOperationGreaterThanOrEqual,
}
var _NumberOperationNames = []string{
@@ -181,7 +181,7 @@ func Test_writeOperation(t *testing.T) {
{
name: "number less than or equal",
write: func(builder *StatementBuilder) {
writeNumberOperation[int](builder, NewColumn("table", "column"), NumberOperationAtLeast, 123)
writeNumberOperation[int](builder, NewColumn("table", "column"), NumberOperationLessThanOrEqual, 123)
},
want: want{
stmt: "table.column <= $1",
@@ -201,7 +201,7 @@ func Test_writeOperation(t *testing.T) {
{
name: "number greater than or equal",
write: func(builder *StatementBuilder) {
writeNumberOperation[int](builder, NewColumn("table", "column"), NumberOperationAtMost, 123)
writeNumberOperation[int](builder, NewColumn("table", "column"), NumberOperationGreaterThanOrEqual, 123)
},
want: want{
stmt: "table.column >= $1",
@@ -0,0 +1,48 @@
package repository
import (
"encoding/json"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
type JSON[T any] struct {
Value T
}
func (j *JSON[T]) Scan(src any) (err error) {
var rawJSON []byte
switch s := src.(type) {
case string:
rawJSON = []byte(s)
case []byte:
rawJSON = s
case nil:
return nil
default:
return ErrScanSource
}
if err = j.UnmarshalJSON(rawJSON); err != nil {
return database.NewScanError(err)
}
return nil
}
// UnmarshalJSON allows encoding/json to decode nested JSON into JSON[T] fields.
// This makes [json.Unmarshal] work for fields inside aggregated JSON (e.g. JSON arrays).
func (j *JSON[T]) UnmarshalJSON(data []byte) error {
if len(data) == 0 || string(data) == "null" {
return nil
}
var v T
if err := json.Unmarshal(data, &v); err != nil {
return err
}
j.Value = v
return nil
}
// MarshalJSON encodes the wrapped value or null if nil.
func (j JSON[T]) MarshalJSON() ([]byte, error) {
return json.Marshal(j.Value)
}
@@ -0,0 +1,102 @@
package repository_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/backend/v3/storage/database"
"github.com/zitadel/zitadel/backend/v3/storage/database/repository"
)
func TestJSON_Scan(t *testing.T) {
type want struct {
err error
res *testObject
}
tests := []struct {
name string
src any
want want
}{
{
name: "nil",
src: nil,
want: want{
err: nil,
res: nil,
},
},
{
name: "[]byte with data",
src: []byte(`{"id":"1","number":1,"active":true}`),
want: want{
err: nil,
res: &testObject{ID: "1", Number: 1, Active: true},
},
},
{
name: "[]byte without data",
src: []byte(`{}`),
want: want{
err: nil,
res: &testObject{},
},
},
{
name: "nil []byte",
src: []byte(nil),
want: want{
err: nil,
res: nil,
},
},
{
name: "string with data",
src: `{"id":"2","number":2,"active":false}`,
want: want{
err: nil,
res: &testObject{ID: "2", Number: 2, Active: false},
},
},
{
name: "string without data",
src: `{}`,
want: want{
err: nil,
res: &testObject{},
},
},
{
name: "empty string",
src: ``,
want: want{
err: nil,
res: nil,
},
},
{
name: "unsupported type",
src: 12345,
want: want{
err: repository.ErrScanSource,
res: nil,
},
},
{
name: "invalid json",
src: []byte(`this is not json`),
want: want{
err: new(database.ScanError),
res: nil,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var a repository.JSON[testObject]
gotErr := a.Scan(tt.src)
require.ErrorIs(t, gotErr, tt.want.err)
})
}
}
@@ -0,0 +1,655 @@
package repository
import (
"context"
"fmt"
"time"
"github.com/muhlemmer/gu"
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
// -------------------------------------------------------------
// repository
// -------------------------------------------------------------
var _ domain.SessionRepository = (*session)(nil)
type session struct {
factorRepo sessionFactor
metadataRepo sessionMetadata
userAgentRepo sessionUserAgent
}
func (s session) qualifiedTableName() string {
return "zitadel.sessions"
}
func (s session) unqualifiedTableName() string {
return "sessions"
}
func SessionRepository() domain.SessionRepository {
return new(session)
}
const querySessionStmt = `
SELECT
sessions.instance_id
, sessions.id
, sessions.token_id
, sessions.lifetime
, sessions.expiration
, sessions.user_id
, sessions.creator_id
, sessions.created_at
, sessions.updated_at
, jsonb_agg(distinct
jsonb_build_object(
'instanceId', session_factors.instance_id
, 'sessionId', session_factors.session_id
, 'type', session_factors.type
, 'lastChallengedAt', session_factors.last_challenged_at
, 'challengedPayload', session_factors.challenged_payload
, 'lastVerifiedAt', session_factors.last_verified_at
, 'verifiedPayload', session_factors.verified_payload
)
)
FILTER (WHERE session_factors.session_id IS NOT NULL) AS factors
, jsonb_agg(distinct
jsonb_build_object(
'instanceId', session_metadata.instance_id
, 'sessionId', session_metadata.session_id
, 'key', session_metadata.key
, 'value', encode(session_metadata.value, 'base64')
, 'createdAt', session_metadata.created_at
, 'updatedAt', session_metadata.updated_at
)
)
FILTER (WHERE session_metadata.session_id IS NOT NULL) AS metadata
, CASE WHEN session_user_agents.fingerprint_id IS NOT NULL THEN
jsonb_build_object(
'fingerprintId', session_user_agents.fingerprint_id
, 'description', session_user_agents.description
, 'ip', session_user_agents.ip
, 'headers', session_user_agents.headers
)
END as user_agent
FROM zitadel.sessions`
// Get implements [domain.SessionRepository].
func (s session) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.Session, error) {
opts = append(opts,
s.joinFactors(),
s.joinMetadata(),
s.joinUserAgent(),
database.WithGroupBy(
s.InstanceIDColumn(),
s.IDColumn(),
s.userAgentRepo.fingerprintIDColumn(),
s.userAgentRepo.descriptionColumn(),
s.userAgentRepo.ipColumn(),
s.userAgentRepo.headersColumn(),
),
)
options := new(database.QueryOpts)
for _, opt := range opts {
opt(options)
}
if !options.Condition.IsRestrictingColumn(s.InstanceIDColumn()) {
return nil, database.NewMissingConditionError(s.InstanceIDColumn())
}
var builder database.StatementBuilder
builder.WriteString(querySessionStmt)
options.Write(&builder)
return scanSession(ctx, client, &builder)
}
// List implements [domain.SessionRepository].
func (s session) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.Session, error) {
opts = append(opts,
s.joinFactors(),
s.joinMetadata(),
s.joinUserAgent(),
database.WithGroupBy(
s.InstanceIDColumn(),
s.IDColumn(),
s.userAgentRepo.fingerprintIDColumn(),
s.userAgentRepo.descriptionColumn(),
s.userAgentRepo.ipColumn(),
s.userAgentRepo.headersColumn(),
),
)
options := new(database.QueryOpts)
for _, opt := range opts {
opt(options)
}
if options.Condition == nil || !options.Condition.IsRestrictingColumn(s.InstanceIDColumn()) {
return nil, database.NewMissingConditionError(s.InstanceIDColumn())
}
var builder database.StatementBuilder
builder.WriteString(querySessionStmt)
options.Write(&builder)
return scanSessions(ctx, client, &builder)
}
const upsertSessionUserAgentStmt = `
WITH user_agent AS (
INSERT INTO zitadel.session_user_agents(
instance_id, fingerprint_id, description, ip, headers
)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (instance_id, fingerprint_id)
DO UPDATE SET description = EXCLUDED.description, ip = EXCLUDED.ip, headers = excluded.headers
) `
// Create implements [domain.SessionRepository].
func (s session) Create(ctx context.Context, client database.QueryExecutor, session *domain.Session) error {
var createdAt any = database.DefaultInstruction
if !session.CreatedAt.IsZero() {
createdAt = session.CreatedAt
}
var updatedAt = createdAt
if !session.UpdatedAt.IsZero() {
updatedAt = session.UpdatedAt
}
builder := new(database.StatementBuilder)
var fingerprintID *string
if session.UserAgent != nil {
builder = database.NewStatementBuilder(upsertSessionUserAgentStmt,
session.InstanceID,
session.UserAgent.FingerprintID,
session.UserAgent.Description,
session.UserAgent.IP,
session.UserAgent.Header,
)
fingerprintID = session.UserAgent.FingerprintID
}
builder.WriteString(`INSERT INTO ` + s.qualifiedTableName() + ` (instance_id, id, lifetime, creator_id, user_agent_id, created_at, updated_at) VALUES ( `)
builder.WriteArgs(session.InstanceID, session.ID, session.Lifetime, session.CreatorID, fingerprintID, createdAt, updatedAt)
builder.WriteString(` ) RETURNING created_at, updated_at`)
return client.QueryRow(ctx, builder.String(), builder.Args()...).Scan(&session.CreatedAt, &session.UpdatedAt)
}
// Update implements [domain.SessionRepository].
func (s session) Update(ctx context.Context, client database.QueryExecutor, condition database.Condition, changes ...database.Change) (int64, error) {
if len(changes) == 0 {
return 0, database.ErrNoChanges
}
if !condition.IsRestrictingColumn(s.InstanceIDColumn()) {
return 0, database.NewMissingConditionError(s.InstanceIDColumn())
}
if !database.Changes(changes).IsOnColumn(s.UpdatedAtColumn()) {
changes = append(changes, database.NewChange(s.UpdatedAtColumn(), database.DefaultInstruction))
}
var builder database.StatementBuilder
builder.WriteString("WITH existing_session AS (SELECT * FROM zitadel.sessions ")
writeCondition(&builder, condition)
builder.WriteString(") ")
for i, change := range changes {
sessionCTE(change, i, 0, &builder)
}
builder.WriteString("UPDATE zitadel.sessions SET ")
if err := database.Changes(changes).Write(&builder); err != nil {
return 0, err
}
writeCondition(&builder, condition)
return client.Exec(ctx, builder.String(), builder.Args()...)
}
func sessionCTE(change database.Change, i, j int, builder *database.StatementBuilder) {
if multi, ok := change.(database.Changes); ok {
for j, ch := range multi {
sessionCTE(ch, i, j, builder)
}
}
if cte, ok := change.(database.CTEChange); ok {
name := fmt.Sprintf("cte_%d_%d", i, j)
fmt.Fprintf(builder, ", %s as (", name)
cte.SetName(name)
cte.WriteCTE(builder)
builder.WriteString(") ")
}
}
// Delete implements [domain.SessionRepository].
func (s session) Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) {
if !condition.IsRestrictingColumn(s.InstanceIDColumn()) {
return 0, database.NewMissingConditionError(s.InstanceIDColumn())
}
var builder database.StatementBuilder
builder.WriteString("DELETE FROM zitadel.sessions")
writeCondition(&builder, condition)
return client.Exec(ctx, builder.String(), builder.Args()...)
}
// -------------------------------------------------------------
// changes
// -------------------------------------------------------------
// SetUpdatedAt implements [domain.sessionChanges].
func (s session) SetUpdatedAt(updatedAt time.Time) database.Change {
return database.NewChange(s.UpdatedAtColumn(), updatedAt)
}
// SetToken implements [domain.sessionChanges].
func (s session) SetToken(token string) database.Change {
return database.NewChange(s.TokenIDColumn(), token)
}
// SetLifetime implements [domain.sessionChanges].
func (s session) SetLifetime(lifetime time.Duration) database.Change {
return database.NewChange(s.LifetimeColumn(), lifetime)
}
// SetChallenge implements [domain.sessionChanges].
func (s session) SetChallenge(challenge domain.SessionChallenge) database.Change {
switch c := challenge.(type) {
case *domain.SessionChallengePasskey:
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("INSERT INTO zitadel.session_factors (instance_id, session_id, type, last_challenged_at, challenged_payload) SELECT instance_id, id, ")
builder.WriteArgs(domain.SessionFactorTypePasskey, c.LastChallengedAt, c)
builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, type) DO UPDATE SET last_challenged_at = EXCLUDED.last_challenged_at, challenged_payload = EXCLUDED.challenged_payload")
}, nil,
)
case *domain.SessionChallengeOTPSMS:
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("INSERT INTO zitadel.session_factors (instance_id, session_id, type, last_challenged_at, challenged_payload) SELECT instance_id, id, ")
builder.WriteArgs(domain.SessionFactorTypeOTPSMS, c.LastChallengedAt, c)
builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, type) DO UPDATE SET last_challenged_at = EXCLUDED.last_challenged_at, challenged_payload = EXCLUDED.challenged_payload")
}, nil,
)
case *domain.SessionChallengeOTPEmail:
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("INSERT INTO zitadel.session_factors (instance_id, session_id, type, last_challenged_at, challenged_payload) SELECT instance_id, id, ")
builder.WriteArgs(domain.SessionFactorTypeOTPEmail, c.LastChallengedAt, c)
builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, type) DO UPDATE SET last_challenged_at = EXCLUDED.last_challenged_at, challenged_payload = EXCLUDED.challenged_payload")
}, nil,
)
}
return nil
}
// SetFactor implements [domain.sessionChanges].
func (s session) SetFactor(factor domain.SessionFactor) database.Change {
switch f := factor.(type) {
case *domain.SessionFactorUser:
return database.NewChanges(
database.NewChange(s.UserIDColumn(), f.UserID),
database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("INSERT INTO zitadel.session_factors (instance_id, session_id, type, last_verified_at, verified_payload) SELECT instance_id, id, ")
builder.WriteArgs(domain.SessionFactorTypeUser, f.LastVerifiedAt, f)
builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, type) DO UPDATE SET last_verified_at = EXCLUDED.last_verified_at, verified_payload = EXCLUDED.verified_payload")
}, nil,
),
)
case *domain.SessionFactorPassword:
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("INSERT INTO zitadel.session_factors (instance_id, session_id, type, last_verified_at) SELECT instance_id, id, ")
builder.WriteArgs(domain.SessionFactorTypePassword, f.LastVerifiedAt)
builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, type) DO UPDATE SET last_verified_at = EXCLUDED.last_verified_at")
}, nil,
)
case *domain.SessionFactorIdentityProviderIntent:
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("INSERT INTO zitadel.session_factors (instance_id, session_id, type, last_verified_at) SELECT instance_id, id, ")
builder.WriteArgs(domain.SessionFactorTypeIdentityProviderIntent, f.LastVerifiedAt)
builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, type) DO UPDATE SET last_verified_at = EXCLUDED.last_verified_at")
}, nil,
)
case *domain.SessionFactorPasskey:
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("INSERT INTO zitadel.session_factors (instance_id, session_id, type, last_verified_at, verified_payload) SELECT instance_id, id, ")
builder.WriteArgs(domain.SessionFactorTypePasskey, f.LastVerifiedAt, f)
builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, type) DO UPDATE SET last_verified_at = EXCLUDED.last_verified_at, verified_payload = EXCLUDED.verified_payload")
}, nil,
)
case *domain.SessionFactorTOTP:
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("INSERT INTO zitadel.session_factors (instance_id, session_id, type, last_verified_at) SELECT instance_id, id, ")
builder.WriteArgs(domain.SessionFactorTypeTOTP, f.LastVerifiedAt)
builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, type) DO UPDATE SET last_verified_at = EXCLUDED.last_verified_at")
}, nil,
)
case *domain.SessionFactorOTPSMS:
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("INSERT INTO zitadel.session_factors (instance_id, session_id, type, last_verified_at) SELECT instance_id, id, ")
builder.WriteArgs(domain.SessionFactorTypeOTPSMS, f.LastVerifiedAt)
builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, type) DO UPDATE SET last_verified_at = EXCLUDED.last_verified_at")
}, nil,
)
case *domain.SessionFactorOTPEmail:
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("INSERT INTO zitadel.session_factors (instance_id, session_id, type, last_verified_at) SELECT instance_id, id, ")
builder.WriteArgs(domain.SessionFactorTypeOTPEmail, f.LastVerifiedAt)
builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, type) DO UPDATE SET last_verified_at = EXCLUDED.last_verified_at")
}, nil,
)
case *domain.SessionFactorRecoveryCode:
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("INSERT INTO zitadel.session_factors (instance_id, session_id, type, last_verified_at) SELECT instance_id, id, ")
builder.WriteArgs(domain.SessionFactorTypeRecoveryCode, f.LastVerifiedAt)
builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, type) DO UPDATE SET last_verified_at = EXCLUDED.last_verified_at")
}, nil,
)
default:
return nil
}
}
// ClearFactor implements [domain.sessionChanges].
func (s session) ClearFactor(factorType domain.SessionFactorType) database.Change {
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("UPDATE zitadel.session_factors sf SET last_verified_at = NULL FROM existing_session es WHERE sf.instance_id = es.instance_id AND sf.session_id = es.id AND sf.type = ")
builder.WriteArg(factorType)
}, nil)
}
// SetMetadata implements [domain.sessionChanges].
func (s session) SetMetadata(metadata []*domain.SessionMetadata) database.Change {
changes := make([]database.Change, len(metadata)+1)
keys := make([]any, len(metadata))
for i, md := range metadata {
keys[i] = md.Key
changes[i] = database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("INSERT INTO zitadel.session_metadata (instance_id, session_id, key, value) SELECT instance_id, id, ")
builder.WriteArgs(md.Key, md.Value)
builder.WriteString(" FROM existing_session ON CONFLICT (instance_id, session_id, key) DO UPDATE SET value = EXCLUDED.value")
}, nil,
)
}
changes[len(metadata)] = database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("DELETE FROM zitadel.session_metadata WHERE instance_id = (SELECT instance_id FROM existing_session) AND session_id = (SELECT id from existing_session) AND key NOT IN (")
builder.WriteArgs(keys...)
builder.WriteString(")")
}, nil)
return database.NewChanges(changes...)
}
// -------------------------------------------------------------
// conditions
// -------------------------------------------------------------
// PrimaryKeyCondition implements [domain.sessionConditions].
func (s session) PrimaryKeyCondition(instanceID, sessionID string) database.Condition {
return database.And(
s.InstanceIDCondition(instanceID),
s.IDCondition(sessionID),
)
}
// InstanceIDCondition implements [domain.sessionConditions].
func (s session) InstanceIDCondition(instanceID string) database.Condition {
return database.NewTextCondition(s.InstanceIDColumn(), database.TextOperationEqual, instanceID)
}
// IDCondition implements [domain.sessionConditions].
func (s session) IDCondition(sessionID string) database.Condition {
return database.NewTextCondition(s.IDColumn(), database.TextOperationEqual, sessionID)
}
// UserAgentIDCondition implements [domain.sessionConditions].
func (s session) UserAgentIDCondition(userAgentID string) database.Condition {
return database.NewTextCondition(s.userAgentIDColumn(), database.TextOperationEqual, userAgentID)
}
// UserIDCondition implements [domain.sessionConditions].
func (s session) UserIDCondition(userID string) database.Condition {
return database.NewTextCondition(s.UserIDColumn(), database.TextOperationEqual, userID)
}
// CreatorIDCondition implements [domain.sessionConditions].
func (s session) CreatorIDCondition(creatorID string) database.Condition {
return database.NewTextCondition(s.CreatorIDColumn(), database.TextOperationEqual, creatorID)
}
// ExpirationCondition implements [domain.sessionConditions].
func (s session) ExpirationCondition(op database.NumberOperation, expiration time.Time) database.Condition {
return database.NewNumberCondition(s.ExpirationColumn(), op, expiration)
}
// CreatedAtCondition implements [domain.sessionConditions].
func (s session) CreatedAtCondition(op database.NumberOperation, createdAt time.Time) database.Condition {
return database.NewNumberCondition(s.CreatedAtColumn(), op, createdAt)
}
// UpdatedAtCondition implements [domain.sessionConditions].
func (s session) UpdatedAtCondition(op database.NumberOperation, updatedAt time.Time) database.Condition {
return database.NewNumberCondition(s.UpdatedAtColumn(), op, updatedAt)
}
// ExistsFactor implements [domain.sessionConditions].
func (s session) ExistsFactor(cond database.Condition) database.Condition {
return database.Exists(
s.factorRepo.qualifiedTableName(),
database.And(
database.NewColumnCondition(s.InstanceIDColumn(), s.factorRepo.instanceIDColumn()),
database.NewColumnCondition(s.IDColumn(), s.factorRepo.sessionIDColumn()),
cond,
),
)
}
// FactorConditions implements [domain.sessionConditions].
func (s session) FactorConditions() domain.SessionFactorConditions {
return s.factorRepo
}
// ExistsMetadata implements [domain.sessionConditions].
func (s session) ExistsMetadata(cond database.Condition) database.Condition {
return database.Exists(
s.metadataRepo.qualifiedTableName(),
database.And(
database.NewColumnCondition(s.InstanceIDColumn(), s.metadataRepo.instanceIDColumn()),
database.NewColumnCondition(s.IDColumn(), s.metadataRepo.sessionIDColumn()),
cond,
),
)
}
// MetadataConditions implements [domain.sessionConditions].
func (s session) MetadataConditions() domain.SessionMetadataConditions {
return s.metadataRepo
}
// -------------------------------------------------------------
// columns
// -------------------------------------------------------------
// PrimaryKeyColumns implements [domain.Repository].
func (s session) PrimaryKeyColumns() []database.Column {
return []database.Column{
s.InstanceIDColumn(),
s.IDColumn(),
}
}
// InstanceIDColumn implements [domain.sessionColumns].
func (s session) InstanceIDColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "instance_id")
}
// IDColumn implements [domain.sessionColumns].
func (s session) IDColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "id")
}
// TokenIDColumn implements [domain.sessionColumns].
func (s session) TokenIDColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "token_id")
}
// LifetimeColumn implements [domain.sessionColumns].
func (s session) LifetimeColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "lifetime")
}
// ExpirationColumn implements [domain.sessionColumns].
func (s session) ExpirationColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "expiration")
}
// UserIDColumn implements [domain.sessionColumns].
func (s session) UserIDColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "user_id")
}
// UserAgentIDColumn implements [domain.sessionColumns].
func (s session) userAgentIDColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "user_agent_id")
}
// CreatorIDColumn implements [domain.sessionColumns].
func (s session) CreatorIDColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "creator_id")
}
// CreatedAtColumn implements [domain.sessionColumns].
func (s session) CreatedAtColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "created_at")
}
// UpdatedAtColumn implements [domain.sessionColumns].
func (s session) UpdatedAtColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "updated_at")
}
// -------------------------------------------------------------
// scanners
// -------------------------------------------------------------
type rawSession struct {
*domain.Session
TokenID *string `json:"tokenID" db:"token_id"`
Lifetime *time.Duration `json:"lifetime" db:"lifetime"`
Expiration *time.Time `json:"expiration" db:"expiration"`
UserID *string `json:"userID" db:"user_id"`
CreatorID *string `json:"creatorID" db:"creator_id"`
Factors JSONArray[rawFactor] `json:"factors,omitempty" db:"factors"`
Metadata JSONArray[domain.SessionMetadata] `json:"metadata,omitempty" db:"metadata"`
}
func scanSession(ctx context.Context, querier database.Querier, builder *database.StatementBuilder) (*domain.Session, error) {
rows, err := querier.Query(ctx, builder.String(), builder.Args()...)
if err != nil {
return nil, err
}
raw := new(rawSession)
if err := rows.(database.CollectableRows).CollectExactlyOneRow(raw); err != nil {
return nil, err
}
return rawSessionToDomain(raw)
}
func scanSessions(ctx context.Context, querier database.Querier, builder *database.StatementBuilder) ([]*domain.Session, error) {
rows, err := querier.Query(ctx, builder.String(), builder.Args()...)
if err != nil {
return nil, err
}
var sessions []*rawSession
if err := rows.(database.CollectableRows).Collect(&sessions); err != nil {
return nil, err
}
result := make([]*domain.Session, len(sessions))
for i, session := range sessions {
result[i], err = rawSessionToDomain(session)
if err != nil {
return nil, err
}
}
return result, nil
}
func rawSessionToDomain(raw *rawSession) (*domain.Session, error) {
raw.Session.TokenID = gu.Value(raw.TokenID)
raw.Session.Lifetime = gu.Value(raw.Lifetime)
raw.Session.Expiration = gu.Value(raw.Expiration)
raw.Session.UserID = gu.Value(raw.UserID)
raw.Session.CreatorID = gu.Value(raw.CreatorID)
for _, factor := range raw.Factors {
f, ch, err := factor.ToDomain()
if err != nil {
return nil, err
}
if f != nil {
raw.Session.Factors.AppendTo(f)
}
if ch != nil {
raw.Challenges.AppendTo(ch)
}
}
raw.Session.Metadata = raw.Metadata
return raw.Session, nil
}
// -------------------------------------------------------------
// sub repositories
// -------------------------------------------------------------
func (s session) joinUserAgent() database.QueryOption {
columns := make([]database.Condition, 0, 3)
columns = append(columns,
database.NewColumnCondition(s.InstanceIDColumn(), s.userAgentRepo.instanceIDColumn()),
database.NewColumnCondition(s.userAgentIDColumn(), s.userAgentRepo.fingerprintIDColumn()),
)
return database.WithLeftJoin(
s.userAgentRepo.qualifiedTableName(),
database.And(columns...),
)
}
func (s session) joinFactors() database.QueryOption {
columns := make([]database.Condition, 0, 3)
columns = append(columns,
database.NewColumnCondition(s.InstanceIDColumn(), s.factorRepo.instanceIDColumn()),
database.NewColumnCondition(s.IDColumn(), s.factorRepo.sessionIDColumn()),
)
return database.WithLeftJoin(
s.factorRepo.qualifiedTableName(),
database.And(columns...),
)
}
func (s session) joinMetadata() database.QueryOption {
columns := make([]database.Condition, 0, 3)
columns = append(columns,
database.NewColumnCondition(s.InstanceIDColumn(), s.metadataRepo.instanceIDColumn()),
database.NewColumnCondition(s.IDColumn(), s.metadataRepo.sessionIDColumn()),
)
return database.WithLeftJoin(
s.metadataRepo.qualifiedTableName(),
database.And(columns...),
)
}
@@ -0,0 +1,190 @@
package repository
import (
"encoding/json"
"time"
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
type sessionFactor struct {
}
func (s sessionFactor) qualifiedTableName() string {
return "zitadel.session_factors"
}
func (s sessionFactor) unqualifiedTableName() string {
return "session_factors"
}
func (s sessionFactor) PrimaryKeyColumns() []database.Column {
return []database.Column{
s.instanceIDColumn(),
s.sessionIDColumn(),
s.typeColumn(),
}
}
func (s sessionFactor) instanceIDColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "instance_id")
}
func (s sessionFactor) sessionIDColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "session_id")
}
func (s sessionFactor) typeColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "type")
}
func (s sessionFactor) lastVerifiedAtColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "last_verified_at")
}
// -------------------------------------------------------------
// conditions
// -------------------------------------------------------------
func (s sessionFactor) FactorTypeCondition(factorType domain.SessionFactorType) database.Condition {
return database.NewNumberCondition(s.typeColumn(), database.NumberOperationEqual, factorType)
}
func (s sessionFactor) LastVerifiedBeforeCondition(lastVerifiedAt time.Time) database.Condition {
return database.NewNumberCondition(s.lastVerifiedAtColumn(), database.NumberOperationLessThan, lastVerifiedAt)
}
// -------------------------------------------------------------
// scanners
// -------------------------------------------------------------
type rawFactor struct {
Type string `json:"type" db:"type"`
LastChallengedAt time.Time `json:"lastChallengedAt" db:"last_challenged_at"`
ChallengedPayload JSON[json.RawMessage] `json:"challengedPayload" db:"challenged_payload"`
LastVerifiedAt time.Time `json:"lastVerifiedAt" db:"last_verified_at"`
VerifiedPayload JSON[json.RawMessage] `json:"verifiedPayload" db:"verified_payload"`
}
func (f *rawFactor) ToDomain() (domain.SessionFactor, domain.SessionChallenge, error) {
factorType, err := domain.SessionFactorTypeString(f.Type)
if err != nil {
return nil, nil, err
}
switch factorType {
case domain.SessionFactorTypeUser:
return f.userFactorToDomain()
case domain.SessionFactorTypePassword:
return f.passwordFactorToDomain()
case domain.SessionFactorTypePasskey:
return f.passkeyFactorToDomain()
case domain.SessionFactorTypeIdentityProviderIntent:
return f.identityProviderIntentFactorToDomain()
case domain.SessionFactorTypeTOTP:
return f.totpFactorToDomain()
case domain.SessionFactorTypeOTPSMS:
return f.otpSMSFactorToDomain()
case domain.SessionFactorTypeOTPEmail:
return f.otpEmailFactorToDomain()
case domain.SessionFactorTypeRecoveryCode:
return f.recoveryCodeFactorToDomain()
case domain.SessionFactorTypeUnknown:
return nil, nil, nil
default:
return nil, nil, nil
}
}
func (f *rawFactor) userFactorToDomain() (factor domain.SessionFactor, _ domain.SessionChallenge, err error) {
if f.LastVerifiedAt.IsZero() {
return nil, nil, nil
}
factor = new(domain.SessionFactorUser)
if err := json.Unmarshal(f.VerifiedPayload.Value, factor); err != nil {
return nil, nil, err
}
return factor, nil, nil
}
func (f *rawFactor) passwordFactorToDomain() (domain.SessionFactor, domain.SessionChallenge, error) {
if f.LastVerifiedAt.IsZero() {
return nil, nil, nil
}
return &domain.SessionFactorPassword{
LastVerifiedAt: f.LastVerifiedAt,
}, nil, nil
}
func (f *rawFactor) passkeyFactorToDomain() (factor domain.SessionFactor, challenge domain.SessionChallenge, err error) {
if !f.LastChallengedAt.IsZero() && f.LastChallengedAt.After(f.LastVerifiedAt) {
challenge = new(domain.SessionChallengePasskey)
if err := json.Unmarshal(f.ChallengedPayload.Value, &challenge); err != nil {
return nil, nil, err
}
}
if !f.LastVerifiedAt.IsZero() {
factor = new(domain.SessionFactorPasskey)
if err := json.Unmarshal(f.VerifiedPayload.Value, factor); err != nil {
return nil, nil, err
}
}
return factor, challenge, nil
}
func (f *rawFactor) identityProviderIntentFactorToDomain() (domain.SessionFactor, domain.SessionChallenge, error) {
if f.LastVerifiedAt.IsZero() {
return nil, nil, nil
}
return &domain.SessionFactorIdentityProviderIntent{
LastVerifiedAt: f.LastVerifiedAt,
}, nil, nil
}
func (f *rawFactor) totpFactorToDomain() (domain.SessionFactor, domain.SessionChallenge, error) {
if f.LastVerifiedAt.IsZero() {
return nil, nil, nil
}
return &domain.SessionFactorTOTP{
LastVerifiedAt: f.LastVerifiedAt,
}, nil, nil
}
func (f *rawFactor) otpSMSFactorToDomain() (factor domain.SessionFactor, challenge domain.SessionChallenge, err error) {
if !f.LastChallengedAt.IsZero() && f.LastChallengedAt.After(f.LastVerifiedAt) {
challenge = new(domain.SessionChallengeOTPSMS)
if err := json.Unmarshal(f.ChallengedPayload.Value, &challenge); err != nil {
return nil, nil, err
}
}
if !f.LastVerifiedAt.IsZero() {
factor = &domain.SessionFactorOTPSMS{
LastVerifiedAt: f.LastVerifiedAt,
}
}
return factor, challenge, nil
}
func (f *rawFactor) otpEmailFactorToDomain() (factor domain.SessionFactor, challenge domain.SessionChallenge, err error) {
if !f.LastChallengedAt.IsZero() && f.LastChallengedAt.After(f.LastVerifiedAt) {
challenge = new(domain.SessionChallengeOTPEmail)
if err := json.Unmarshal(f.ChallengedPayload.Value, &challenge); err != nil {
return nil, nil, err
}
}
if !f.LastVerifiedAt.IsZero() {
factor = &domain.SessionFactorOTPEmail{
LastVerifiedAt: f.LastVerifiedAt,
}
}
return factor, challenge, nil
}
func (f *rawFactor) recoveryCodeFactorToDomain() (domain.SessionFactor, domain.SessionChallenge, error) {
if f.LastVerifiedAt.IsZero() {
return nil, nil, nil
}
return &domain.SessionFactorRecoveryCode{
LastVerifiedAt: f.LastVerifiedAt,
}, nil, nil
}
@@ -0,0 +1,71 @@
package repository
import "github.com/zitadel/zitadel/backend/v3/storage/database"
type sessionMetadata struct {
}
func (s sessionMetadata) qualifiedTableName() string {
return "zitadel.session_metadata"
}
func (s sessionMetadata) unqualifiedTableName() string {
return "session_metadata"
}
func (s sessionMetadata) PrimaryKeyColumns() []database.Column {
return []database.Column{
s.instanceIDColumn(),
s.sessionIDColumn(),
s.keyColumn(),
}
}
func (s sessionMetadata) instanceIDColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "instance_id")
}
func (s sessionMetadata) sessionIDColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "session_id")
}
func (s sessionMetadata) keyColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "key")
}
func (s sessionMetadata) valueColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "value")
}
// -------------------------------------------------------------
// conditions
// -------------------------------------------------------------
// PrimaryKeyCondition implements [domain.SessionMetadataRepository].
func (s sessionMetadata) PrimaryKeyCondition(instanceID string, sessionID string, key string) database.Condition {
return database.And(
s.InstanceIDCondition(instanceID),
s.SessionIDCondition(sessionID),
s.KeyCondition(database.TextOperationEqual, key),
)
}
// InstanceIDCondition implements [domain.SessionMetadataRepository].
func (s sessionMetadata) InstanceIDCondition(instanceID string) database.Condition {
return database.NewTextCondition(s.instanceIDColumn(), database.TextOperationEqual, instanceID)
}
// SessionIDCondition implements [domain.SessionMetadataRepository].
func (s sessionMetadata) SessionIDCondition(sessionID string) database.Condition {
return database.NewTextCondition(s.sessionIDColumn(), database.TextOperationEqual, sessionID)
}
// KeyCondition implements [domain.SessionMetadataRepository].
func (s sessionMetadata) KeyCondition(op database.TextOperation, key string) database.Condition {
return database.NewTextCondition(s.keyColumn(), op, key)
}
// ValueCondition implements [domain.SessionMetadataRepository].
func (s sessionMetadata) ValueCondition(op database.BytesOperation, value []byte) database.Condition {
return database.NewBytesCondition[[]byte](database.SHA256Column(s.valueColumn()), op, database.SHA256Value(value))
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,43 @@
package repository
import (
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
type sessionUserAgent struct {
}
func (s sessionUserAgent) qualifiedTableName() string {
return "zitadel.session_user_agents"
}
func (s sessionUserAgent) unqualifiedTableName() string {
return "session_user_agents"
}
func (s sessionUserAgent) PrimaryKeyColumns() []database.Column {
return []database.Column{
s.instanceIDColumn(),
s.fingerprintIDColumn(),
}
}
func (s sessionUserAgent) instanceIDColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "instance_id")
}
func (s sessionUserAgent) fingerprintIDColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "fingerprint_id")
}
func (s sessionUserAgent) descriptionColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "description")
}
func (s sessionUserAgent) ipColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "ip")
}
func (s sessionUserAgent) headersColumn() database.Column {
return database.NewColumn(s.unqualifiedTableName(), "headers")
}