snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
This commit is contained in:
356
identity/credentials.go
Normal file
356
identity/credentials.go
Normal file
@@ -0,0 +1,356 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package identity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/wI2L/jsondiff"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
"github.com/ory/x/sqlxx"
|
||||
)
|
||||
|
||||
// Authenticator Assurance Level (AAL)
|
||||
//
|
||||
// The authenticator assurance level can be one of "aal1", "aal2", or "aal3". A higher number means that it is harder
|
||||
// for an attacker to compromise the account.
|
||||
//
|
||||
// Generally, "aal1" implies that one authentication factor was used while AAL2 implies that two factors (e.g.
|
||||
// password + TOTP) have been used.
|
||||
//
|
||||
// To learn more about these levels please head over to: https://www.ory.com/kratos/docs/concepts/credentials
|
||||
//
|
||||
// swagger:model authenticatorAssuranceLevel
|
||||
type AuthenticatorAssuranceLevel string
|
||||
|
||||
const (
|
||||
NoAuthenticatorAssuranceLevel AuthenticatorAssuranceLevel = "aal0"
|
||||
AuthenticatorAssuranceLevel1 AuthenticatorAssuranceLevel = "aal1"
|
||||
AuthenticatorAssuranceLevel2 AuthenticatorAssuranceLevel = "aal2"
|
||||
)
|
||||
|
||||
type NullableAuthenticatorAssuranceLevel struct {
|
||||
sql.NullString
|
||||
}
|
||||
|
||||
// NewNullableAuthenticatorAssuranceLevel returns a new NullableAuthenticatorAssuranceLevel
|
||||
func NewNullableAuthenticatorAssuranceLevel(aal AuthenticatorAssuranceLevel) NullableAuthenticatorAssuranceLevel {
|
||||
switch aal {
|
||||
case NoAuthenticatorAssuranceLevel:
|
||||
fallthrough
|
||||
case AuthenticatorAssuranceLevel1:
|
||||
fallthrough
|
||||
case AuthenticatorAssuranceLevel2:
|
||||
return NullableAuthenticatorAssuranceLevel{sql.NullString{
|
||||
String: string(aal),
|
||||
Valid: true,
|
||||
}}
|
||||
default:
|
||||
return NullableAuthenticatorAssuranceLevel{sql.NullString{}}
|
||||
}
|
||||
}
|
||||
|
||||
// ToAAL returns the AuthenticatorAssuranceLevel value of the given NullableAuthenticatorAssuranceLevel.
|
||||
func (n NullableAuthenticatorAssuranceLevel) ToAAL() (AuthenticatorAssuranceLevel, bool) {
|
||||
if !n.Valid {
|
||||
return "", false
|
||||
}
|
||||
switch n.String {
|
||||
case string(NoAuthenticatorAssuranceLevel):
|
||||
return NoAuthenticatorAssuranceLevel, true
|
||||
case string(AuthenticatorAssuranceLevel1):
|
||||
return AuthenticatorAssuranceLevel1, true
|
||||
case string(AuthenticatorAssuranceLevel2):
|
||||
return AuthenticatorAssuranceLevel2, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// CredentialsType represents several different credential types, like password credentials, passwordless credentials,
|
||||
// and so on.
|
||||
//
|
||||
// swagger:enum CredentialsType
|
||||
type CredentialsType string
|
||||
|
||||
// Please make sure to add all of these values to the test that ensures they are created during migration
|
||||
const (
|
||||
CredentialsTypePassword CredentialsType = "password"
|
||||
CredentialsTypeOIDC CredentialsType = "oidc"
|
||||
CredentialsTypeTOTP CredentialsType = "totp"
|
||||
CredentialsTypeLookup CredentialsType = "lookup_secret"
|
||||
CredentialsTypeWebAuthn CredentialsType = "webauthn"
|
||||
CredentialsTypeCodeAuth CredentialsType = "code"
|
||||
CredentialsTypePasskey CredentialsType = "passkey"
|
||||
CredentialsTypeProfile CredentialsType = "profile"
|
||||
CredentialsTypeSAML CredentialsType = "saml"
|
||||
CredentialsTypeDeviceAuthn CredentialsType = "deviceauthn"
|
||||
CredentialsTypeIdentifierFirst CredentialsType = "identifier_first" // TODO(jonas): Used only for SDK compatibility. We should refactor all the places that use "CredentialType" as a method identifier (flow.Active fields, etc.)
|
||||
)
|
||||
|
||||
func (c CredentialsType) String() string {
|
||||
return string(c)
|
||||
}
|
||||
|
||||
func (c CredentialsType) ToUiNodeGroup() node.UiNodeGroup {
|
||||
switch c {
|
||||
case CredentialsTypePassword:
|
||||
return node.PasswordGroup
|
||||
case CredentialsTypeOIDC, CredentialsTypeSAML:
|
||||
return node.OpenIDConnectGroup
|
||||
case CredentialsTypeTOTP:
|
||||
return node.TOTPGroup
|
||||
case CredentialsTypeWebAuthn:
|
||||
return node.WebAuthnGroup
|
||||
case CredentialsTypeLookup:
|
||||
return node.LookupGroup
|
||||
case CredentialsTypeCodeAuth:
|
||||
return node.CodeGroup
|
||||
case CredentialsTypePasskey:
|
||||
return node.PasskeyGroup
|
||||
case CredentialsTypeDeviceAuthn:
|
||||
return node.DeviceAuthnGroup
|
||||
case CredentialsTypeIdentifierFirst:
|
||||
return node.IdentifierFirstGroup
|
||||
default:
|
||||
return node.DefaultGroup
|
||||
}
|
||||
}
|
||||
|
||||
var AllCredentialTypes = []CredentialsType{
|
||||
CredentialsTypePassword,
|
||||
CredentialsTypeOIDC,
|
||||
// CredentialsTypeSAML, placeholder for the OEL version
|
||||
CredentialsTypeTOTP,
|
||||
CredentialsTypeLookup,
|
||||
CredentialsTypeWebAuthn,
|
||||
CredentialsTypeCodeAuth,
|
||||
CredentialsTypePasskey,
|
||||
CredentialsTypeDeviceAuthn,
|
||||
}
|
||||
|
||||
const (
|
||||
// CredentialsTypeRecoveryLink is a special credential type linked to the link strategy (recovery flow).
|
||||
// It is not used within the credentials object itself.
|
||||
CredentialsTypeRecoveryLink CredentialsType = "link_recovery"
|
||||
CredentialsTypeRecoveryCode CredentialsType = "code_recovery"
|
||||
)
|
||||
|
||||
// ParseCredentialsType parses a string into a CredentialsType or returns false as the second argument.
|
||||
func ParseCredentialsType(in string) (CredentialsType, bool) {
|
||||
switch t := CredentialsType(in); t {
|
||||
case CredentialsTypePassword,
|
||||
CredentialsTypeOIDC,
|
||||
CredentialsTypeSAML,
|
||||
CredentialsTypeTOTP,
|
||||
CredentialsTypeLookup,
|
||||
CredentialsTypeWebAuthn,
|
||||
CredentialsTypeCodeAuth,
|
||||
CredentialsTypeRecoveryLink,
|
||||
CredentialsTypeRecoveryCode,
|
||||
CredentialsTypeDeviceAuthn,
|
||||
CredentialsTypePasskey:
|
||||
return t, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Credentials represents a specific credential type
|
||||
//
|
||||
// swagger:model identityCredentials
|
||||
type Credentials struct {
|
||||
ID uuid.UUID `json:"-" db:"id"`
|
||||
|
||||
// Type discriminates between different types of credentials.
|
||||
Type CredentialsType `json:"type" db:"-"`
|
||||
IdentityCredentialTypeID uuid.UUID `json:"-" db:"identity_credential_type_id"`
|
||||
|
||||
// Identifiers represent a list of unique identifiers this credential type matches.
|
||||
Identifiers []string `json:"identifiers" db:"-"`
|
||||
|
||||
// Config contains the concrete credential payload. This might contain the bcrypt-hashed password, the email
|
||||
// for passwordless authentication or access_token and refresh tokens from OpenID Connect flows.
|
||||
Config sqlxx.JSONRawMessage `json:"config,omitempty" db:"config"`
|
||||
|
||||
// Version refers to the version of the credential. Useful when changing the config schema.
|
||||
Version int `json:"version" db:"version"`
|
||||
|
||||
IdentityID uuid.UUID `json:"-" faker:"-" db:"identity_id"`
|
||||
|
||||
// CreatedAt is a helper struct field for gobuffalo.pop.
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
|
||||
// UpdatedAt is a helper struct field for gobuffalo.pop.
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
NID uuid.UUID `json:"-" faker:"-" db:"nid"`
|
||||
}
|
||||
|
||||
func (c Credentials) TableName(context.Context) string {
|
||||
return "identity_credentials"
|
||||
}
|
||||
|
||||
func (c Credentials) GetID() uuid.UUID {
|
||||
return c.ID
|
||||
}
|
||||
|
||||
// Signature returns a unique string signature for the credential.
|
||||
func (c Credentials) Signature() string {
|
||||
sortedIdentifiers := slices.Clone(c.Identifiers)
|
||||
slices.Sort(sortedIdentifiers)
|
||||
identifiersStr := strings.Join(sortedIdentifiers, ",")
|
||||
|
||||
// Normalize JSON config to remove whitespace and key ordering differences
|
||||
var normalizedConfig any
|
||||
if len(c.Config) > 0 {
|
||||
if err := json.Unmarshal(c.Config, &normalizedConfig); err != nil {
|
||||
// there is not much we can do when unmarshal fails except use the raw value
|
||||
normalizedConfig = c.Config
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%v|%v|%d|%+v|%v|%v", c.Type, identifiersStr, c.Version, normalizedConfig, c.IdentityID, c.NID)
|
||||
}
|
||||
|
||||
type (
|
||||
// swagger:ignore
|
||||
CredentialIdentifier struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
Identifier string `db:"identifier"`
|
||||
IdentityID *uuid.UUID `json:"-" db:"identity_id"`
|
||||
IdentityCredentialsID uuid.UUID `json:"-" db:"identity_credential_id"`
|
||||
IdentityCredentialsTypeID uuid.UUID `json:"-" db:"identity_credential_type_id"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
NID uuid.UUID `json:"-" faker:"-" db:"nid"`
|
||||
}
|
||||
|
||||
// swagger:ignore
|
||||
CredentialsTypeTable struct {
|
||||
ID uuid.UUID `json:"-" db:"id"`
|
||||
Name CredentialsType `json:"-" db:"name"`
|
||||
}
|
||||
|
||||
// swagger:ignore
|
||||
ActiveCredentialsCounter interface {
|
||||
ID() CredentialsType
|
||||
CountActiveFirstFactorCredentials(context.Context, map[CredentialsType]Credentials) (int, error)
|
||||
CountActiveMultiFactorCredentials(context.Context, map[CredentialsType]Credentials) (int, error)
|
||||
}
|
||||
|
||||
// swagger:ignore
|
||||
ActiveCredentialsCounterStrategyProvider interface {
|
||||
ActiveCredentialsCounterStrategies(context.Context) []ActiveCredentialsCounter
|
||||
}
|
||||
)
|
||||
|
||||
func (c CredentialsTypeTable) TableName(context.Context) string {
|
||||
return "identity_credential_types"
|
||||
}
|
||||
|
||||
func (c CredentialIdentifier) TableName(context.Context) string {
|
||||
return "identity_credential_identifiers"
|
||||
}
|
||||
|
||||
// ValidateCredentialsIntegrity returns a BadRequest error if any entry in
|
||||
// the credentials map has a Type field that does not equal its map key.
|
||||
//
|
||||
// The internal helpers (SetCredentials, SetCredentialsWithConfig,
|
||||
// UpsertCredentialsConfig) always force these two to match, so this only
|
||||
// trips for callers that write to the map directly — notably the PATCH
|
||||
// /admin/identities/{id} handler, which decodes the patched JSON straight
|
||||
// into Identity. Caught early, this is a malformed request. Caught late,
|
||||
// it has two destructive failure modes: a misleading 500 from the
|
||||
// persister when the type is unknown, or a silent overwrite of the
|
||||
// existing credential's config (e.g. hashed_password) when schema
|
||||
// validation later repairs Type but not Config.
|
||||
func ValidateCredentialsIntegrity(creds map[CredentialsType]Credentials) error {
|
||||
for k, c := range creds {
|
||||
if c.Type != k {
|
||||
return errors.WithStack(herodot.ErrBadRequest().WithReasonf(
|
||||
"credentials.%s.type must equal %q, got %q", k, string(k), string(c.Type),
|
||||
))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CredentialsEqual(a, b map[CredentialsType]Credentials) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for k, expect := range b {
|
||||
actual, found := a[k]
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try to normalize configs (remove spaces etc).
|
||||
patch, err := jsondiff.CompareJSON(actual.Config, expect.Config)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(patch) > 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
expectIdentifiers, actualIdentifiers := make(map[string]struct{}, len(expect.Identifiers)), make(map[string]struct{}, len(actual.Identifiers))
|
||||
for _, i := range expect.Identifiers {
|
||||
expectIdentifiers[i] = struct{}{}
|
||||
}
|
||||
for _, i := range actual.Identifiers {
|
||||
actualIdentifiers[i] = struct{}{}
|
||||
}
|
||||
if !reflect.DeepEqual(expectIdentifiers, actualIdentifiers) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// UpdateConfig adapts a typed, in-place credential-config mutation into the
|
||||
// raw mutate form PrivilegedPool.UpdateCredentialsConfig takes:
|
||||
//
|
||||
// pool.UpdateCredentialsConfig(ctx, id, ct, identity.UpdateConfig(mutate))
|
||||
//
|
||||
// T must model the complete stored config shape: unknown fields do not
|
||||
// survive the roundtrip. An empty or JSON-null config is rejected before
|
||||
// mutate runs. mutate must be pure (it may run more than once on database
|
||||
// retries); returning an error aborts without persisting.
|
||||
func UpdateConfig[T any](mutate func(*T) error) func(config []byte) ([]byte, error) {
|
||||
return func(config []byte) ([]byte, error) {
|
||||
if len(config) == 0 || bytes.Equal(config, []byte("null")) {
|
||||
return nil, errors.WithStack(herodot.ErrInternalServerError().WithReason("The stored credential configuration is empty and cannot be updated."))
|
||||
}
|
||||
var decoded T
|
||||
if err := json.Unmarshal(config, &decoded); err != nil {
|
||||
return nil, errors.WithStack(herodot.ErrInternalServerError().WithReason("The stored credential configuration could not be decoded.").WithDebug(err.Error()).WithWrap(err))
|
||||
}
|
||||
if err := mutate(&decoded); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoded, err := json.Marshal(&decoded)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(herodot.ErrInternalServerError().WithReason("The updated credential configuration could not be encoded.").WithDebug(err.Error()).WithWrap(err))
|
||||
}
|
||||
return encoded, nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user