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
357 lines
12 KiB
Go
357 lines
12 KiB
Go
// 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
|
|
}
|
|
}
|