diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 26df4ffc97a3..9c2ee0555b42 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -88,13 +88,12 @@ jobs: - run: npm install name: Install node deps - name: Run golangci-lint - uses: golangci/golangci-lint-action@v4 + uses: golangci/golangci-lint-action@v6 env: GOGC: 100 with: args: --timeout 10m0s - version: v1.56.2 - skip-pkg-cache: true + version: v1.59.1 - name: Build Kratos run: make install - name: Run go-acc (tests) diff --git a/.golangci.yml b/.golangci.yml index 374c9204ed1b..e83dd5a56a2e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -19,14 +19,12 @@ linters-settings: goimports: local-prefixes: github.com/ory -run: - skip-dirs: +issues: + exclude-dirs: - sdk/ - skip-files: + exclude-files: - ".+_test.go" - "corpx/faker.go" - -issues: exclude: - "Set is deprecated: use context-based WithConfigValue instead" - "SetDefaultIdentitySchemaFromRaw is deprecated: Use context-based WithDefaultIdentitySchemaFromRaw instead" diff --git a/Makefile b/Makefile index 61e4284d3994..7af282d469c3 100644 --- a/Makefile +++ b/Makefile @@ -49,7 +49,7 @@ docs/swagger: npx @redocly/openapi-cli preview-docs spec/swagger.json .bin/golangci-lint: Makefile - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -d -b .bin v1.56.2 + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -d -b .bin v1.59.1 .bin/hydra: Makefile bash <(curl https://raw.githubusercontent.com/ory/meta/master/install.sh) -d -b .bin hydra v2.2.0-rc.3 diff --git a/driver/config/config.go b/driver/config/config.go index 9f3c1b38938b..ac394d7c8518 100644 --- a/driver/config/config.go +++ b/driver/config/config.go @@ -203,6 +203,7 @@ const ( ViperKeyClientHTTPPrivateIPExceptionURLs = "clients.http.private_ip_exception_urls" ViperKeyPreviewDefaultReadConsistencyLevel = "preview.default_read_consistency_level" ViperKeyVersion = "version" + ViperKeyPasswordMigrationHook = "selfservice.flows.login.password_migration" ) const ( @@ -290,6 +291,10 @@ type ( Headers map[string]string `json:"headers" koanf:"headers"` LocalName string `json:"local_name" koanf:"local_name"` } + PasswordMigrationHook struct { + Enabled bool `json:"enabled"` + Config json.RawMessage `json:"config"` + } Config struct { l *logrusx.Logger p *configx.Provider @@ -518,13 +523,13 @@ func (p *Config) cors(ctx context.Context, prefix string) (cors.Options, bool) { }) } -// Deprecatd: use context-based WithConfigValue instead -func (p *Config) Set(ctx context.Context, key string, value interface{}) error { +// Deprecated: use context-based WithConfigValue instead +func (p *Config) Set(_ context.Context, key string, value interface{}) error { return p.p.Set(key, value) } // Deprecated: use context-based WithConfigValue instead -func (p *Config) MustSet(ctx context.Context, key string, value interface{}) { +func (p *Config) MustSet(_ context.Context, key string, value interface{}) { if err := p.p.Set(key, value); err != nil { p.l.WithError(err).Fatalf("Unable to set \"%s\" to \"%s\".", key, value) } @@ -1599,3 +1604,11 @@ func (p *Config) TokenizeTemplate(ctx context.Context, key string) (_ *SessionTo func (p *Config) DefaultConsistencyLevel(ctx context.Context) crdbx.ConsistencyLevel { return crdbx.ConsistencyLevelFromString(p.GetProvider(ctx).String(ViperKeyPreviewDefaultReadConsistencyLevel)) } + +func (p *Config) PasswordMigrationHook(ctx context.Context) (hook *PasswordMigrationHook) { + hook = new(PasswordMigrationHook) + // Error is ignored on purpose, as we then default to a hook with `enabled = false`. + _ = p.GetProvider(ctx).Unmarshal(ViperKeyPasswordMigrationHook, hook) + + return hook +} diff --git a/embedx/config.schema.json b/embedx/config.schema.json index 5ebbdb1241ee..e763c402a91a 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -43,10 +43,7 @@ "description": "Ory Kratos redirects to this URL per default on completion of self-service flows and other browser interaction. Read this [article for more information on browser redirects](https://www.ory.sh/kratos/docs/concepts/browser-redirect-flow-completion).", "type": "string", "format": "uri-reference", - "examples": [ - "https://my-app.com/dashboard", - "/dashboard" - ] + "examples": ["https://my-app.com/dashboard", "/dashboard"] }, "selfServiceSessionRevokerHook": { "type": "object", @@ -56,9 +53,7 @@ } }, "additionalProperties": false, - "required": [ - "hook" - ] + "required": ["hook"] }, "selfServiceSessionIssuerHook": { "type": "object", @@ -68,9 +63,7 @@ } }, "additionalProperties": false, - "required": [ - "hook" - ] + "required": ["hook"] }, "selfServiceRequireVerifiedAddressHook": { "type": "object", @@ -80,9 +73,7 @@ } }, "additionalProperties": false, - "required": [ - "hook" - ] + "required": ["hook"] }, "selfServiceVerificationHook": { "type": "object", @@ -92,9 +83,7 @@ } }, "additionalProperties": false, - "required": [ - "hook" - ] + "required": ["hook"] }, "selfServiceShowVerificationUIHook": { "type": "object", @@ -104,9 +93,7 @@ } }, "additionalProperties": false, - "required": [ - "hook" - ] + "required": ["hook"] }, "b2bSSOHook": { "type": "object", @@ -120,10 +107,7 @@ } }, "additionalProperties": false, - "required": [ - "hook", - "config" - ] + "required": ["hook", "config"] }, "webHookAuthBasicAuthProperties": { "properties": { @@ -143,17 +127,11 @@ } }, "additionalProperties": false, - "required": [ - "user", - "password" - ] + "required": ["user", "password"] } }, "additionalProperties": false, - "required": [ - "type", - "config" - ] + "required": ["type", "config"] }, "httpRequestConfig": { "type": "object", @@ -161,9 +139,7 @@ "url": { "title": "HTTP address of API endpoint", "description": "This URL will be used to send the emails to.", - "examples": [ - "https://example.com/api/v1/email" - ], + "examples": ["https://example.com/api/v1/email"], "type": "string", "pattern": "^https?://" }, @@ -228,25 +204,15 @@ "in": { "type": "string", "description": "How the api key should be transferred", - "enum": [ - "header", - "cookie" - ] + "enum": ["header", "cookie"] } }, "additionalProperties": false, - "required": [ - "name", - "value", - "in" - ] + "required": ["name", "value", "in"] } }, "additionalProperties": false, - "required": [ - "type", - "config" - ] + "required": ["type", "config"] }, "selfServiceWebHook": { "type": "object", @@ -285,10 +251,7 @@ "const": true } }, - "required": [ - "ignore", - "parse" - ] + "required": ["ignore", "parse"] } }, "url": { @@ -361,46 +324,30 @@ "response": { "properties": { "ignore": { - "enum": [ - true - ] + "enum": [true] } }, - "required": [ - "ignore" - ] + "required": ["ignore"] } }, - "required": [ - "response" - ] + "required": ["response"] } }, { "properties": { "can_interrupt": { - "enum": [ - false - ] + "enum": [false] } }, - "require": [ - "can_interrupt" - ] + "require": ["can_interrupt"] } ], "additionalProperties": false, - "required": [ - "url", - "method" - ] + "required": ["url", "method"] } }, "additionalProperties": false, - "required": [ - "hook", - "config" - ] + "required": ["hook", "config"] }, "OIDCClaims": { "title": "OpenID Connect claims", @@ -433,9 +380,7 @@ "essential": true }, "acr": { - "values": [ - "urn:mace:incommon:iap:silver" - ] + "values": ["urn:mace:incommon:iap:silver"] } } } @@ -483,9 +428,7 @@ "properties": { "id": { "type": "string", - "examples": [ - "google" - ] + "examples": ["google"] }, "provider": { "title": "Provider", @@ -514,9 +457,7 @@ "lark", "x" ], - "examples": [ - "google" - ] + "examples": ["google"] }, "label": { "title": "Optional string which will be used when generating labels for UI buttons.", @@ -531,23 +472,17 @@ "issuer_url": { "type": "string", "format": "uri", - "examples": [ - "https://accounts.google.com" - ] + "examples": ["https://accounts.google.com"] }, "auth_url": { "type": "string", "format": "uri", - "examples": [ - "https://accounts.google.com/o/oauth2/v2/auth" - ] + "examples": ["https://accounts.google.com/o/oauth2/v2/auth"] }, "token_url": { "type": "string", "format": "uri", - "examples": [ - "https://www.googleapis.com/oauth2/v4/token" - ] + "examples": ["https://www.googleapis.com/oauth2/v4/token"] }, "mapper_url": { "title": "Jsonnet Mapper URL", @@ -564,10 +499,7 @@ "type": "array", "items": { "type": "string", - "examples": [ - "offline_access", - "profile" - ] + "examples": ["offline_access", "profile"] } }, "microsoft_tenant": { @@ -586,30 +518,21 @@ "title": "Microsoft subject source", "description": "Controls which source the subject identifier is taken from by microsoft provider. If set to `userinfo` (the default) then the identifier is taken from the `sub` field of OIDC ID token or data received from `/userinfo` standard OIDC endpoint. If set to `me` then the `id` field of data structure received from `https://graph.microsoft.com/v1.0/me` is taken as an identifier.", "type": "string", - "enum": [ - "userinfo", - "me" - ], + "enum": ["userinfo", "me"], "default": "userinfo", - "examples": [ - "userinfo" - ] + "examples": ["userinfo"] }, "apple_team_id": { "title": "Apple Developer Team ID", "description": "Apple Developer Team ID needed for generating a JWT token for client secret", "type": "string", - "examples": [ - "KP76DQS54M" - ] + "examples": ["KP76DQS54M"] }, "apple_private_key_id": { "title": "Apple Private Key Identifier", "description": "Sign In with Apple Private Key Identifier needed for generating a JWT token for client secret", "type": "string", - "examples": [ - "UX56C66723" - ] + "examples": ["UX56C66723"] }, "apple_private_key": { "title": "Apple Private Key", @@ -626,42 +549,27 @@ "title": "Organization ID", "description": "The ID of the organization that this provider belongs to. Only effective in the Ory Network.", "type": "string", - "examples": [ - "12345678-1234-1234-1234-123456789012" - ] + "examples": ["12345678-1234-1234-1234-123456789012"] }, "additional_id_token_audiences": { "title": "Additional client ids allowed when using ID token submission", "type": "array", "items": { "type": "string", - "examples": [ - "12345678-1234-1234-1234-123456789012" - ] + "examples": ["12345678-1234-1234-1234-123456789012"] } }, "claims_source": { "title": "Claims source", "description": "Can be either `userinfo` (calls the userinfo endpoint to get the claims) or `id_token` (takes the claims from the id token). It defaults to `id_token`", "type": "string", - "enum": [ - "id_token", - "userinfo" - ], + "enum": ["id_token", "userinfo"], "default": "id_token", - "examples": [ - "id_token", - "userinfo" - ] + "examples": ["id_token", "userinfo"] } }, "additionalProperties": false, - "required": [ - "id", - "provider", - "client_id", - "mapper_url" - ], + "required": ["id", "provider", "client_id", "mapper_url"], "allOf": [ { "if": { @@ -670,23 +578,17 @@ "const": "microsoft" } }, - "required": [ - "provider" - ] + "required": ["provider"] }, "then": { - "required": [ - "microsoft_tenant" - ] + "required": ["microsoft_tenant"] }, "else": { "not": { "properties": { "microsoft_tenant": {} }, - "required": [ - "microsoft_tenant" - ] + "required": ["microsoft_tenant"] } } }, @@ -697,9 +599,7 @@ "const": "apple" } }, - "required": [ - "provider" - ] + "required": ["provider"] }, "then": { "not": { @@ -709,9 +609,7 @@ "minLength": 1 } }, - "required": [ - "client_secret" - ] + "required": ["client_secret"] }, "required": [ "apple_private_key_id", @@ -720,9 +618,7 @@ ] }, "else": { - "required": [ - "client_secret" - ], + "required": ["client_secret"], "allOf": [ { "not": { @@ -732,9 +628,7 @@ "minLength": 1 } }, - "required": [ - "apple_team_id" - ] + "required": ["apple_team_id"] } }, { @@ -745,9 +639,7 @@ "minLength": 1 } }, - "required": [ - "apple_private_key_id" - ] + "required": ["apple_private_key_id"] } }, { @@ -758,9 +650,7 @@ "minLength": 1 } }, - "required": [ - "apple_private_key" - ] + "required": ["apple_private_key"] } } ] @@ -940,10 +830,7 @@ "title": "Required Authenticator Assurance Level", "description": "Sets what Authenticator Assurance Level (used for 2FA) is required to access this feature. If set to `highest_available` then this endpoint requires the highest AAL the identity has set up. If set to `aal1` then the identity can access this feature without 2FA.", "type": "string", - "enum": [ - "aal1", - "highest_available" - ], + "enum": ["aal1", "highest_available"], "default": "highest_available" }, "selfServiceAfterSettings": { @@ -1139,9 +1026,7 @@ "path": { "title": "Path to PEM-encoded Fle", "type": "string", - "examples": [ - "path/to/file.pem" - ] + "examples": ["path/to/file.pem"] }, "base64": { "title": "Base64 Encoded Inline", @@ -1189,9 +1074,7 @@ "$ref": "#/definitions/emailCourierTemplate" } }, - "required": [ - "email" - ] + "required": ["email"] }, "valid": { "additionalProperties": false, @@ -1204,9 +1087,7 @@ "$ref": "#/definitions/smsCourierTemplate" } }, - "required": [ - "email" - ] + "required": ["email"] } } }, @@ -1277,9 +1158,7 @@ "selfservice": { "type": "object", "additionalProperties": false, - "required": [ - "default_browser_return_url" - ], + "required": ["default_browser_return_url"], "properties": { "default_browser_return_url": { "$ref": "#/definitions/defaultReturnTo" @@ -1314,30 +1193,20 @@ "description": "URL where the Settings UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": [ - "https://my-app.com/user/settings" - ], + "examples": ["https://my-app.com/user/settings"], "default": "https://www.ory.sh/kratos/docs/fallback/settings" }, "lifespan": { "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] }, "privileged_session_max_age": { "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] }, "required_aal": { "$ref": "#/definitions/featureRequiredAal" @@ -1386,20 +1255,14 @@ "description": "URL where the Registration UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": [ - "https://my-app.com/signup" - ], + "examples": ["https://my-app.com/signup"], "default": "https://www.ory.sh/kratos/docs/fallback/registration" }, "lifespan": { "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] }, "before": { "$ref": "#/definitions/selfServiceBeforeRegistration" @@ -1424,31 +1287,77 @@ "description": "URL where the Login UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": [ - "https://my-app.com/login" - ], + "examples": ["https://my-app.com/login"], "default": "https://www.ory.sh/kratos/docs/fallback/login" }, "lifespan": { "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] }, "style": { "title": "Login Flow Style", "description": "The style of the login flow. If set to `one_step` the login flow will be a one-step process. If set to `identifier_first` (experimental!) the login flow will first ask for the identifier and then the credentials.", "type": "string", - "enum": [ - "one_step", - "identifier_first" - ], + "enum": ["one_step", "identifier_first"], "default": "one_step" }, + "password_migration": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "title": "Enable Password Migration", + "description": "If set to true will enable password migration.", + "default": false + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "string", + "description": "The URL the password migration hook should call", + "format": "uri" + }, + "method": { + "type": "string", + "description": "The HTTP method to use (GET, POST, etc).", + "const": "POST", + "default": "POST" + }, + "headers": { + "type": "object", + "description": "The HTTP headers that must be applied to the password migration hook.", + "additionalProperties": { + "type": "string" + } + }, + "emit_analytics_event": { + "type": "boolean", + "default": true, + "description": "Emit tracing events for this hook on delivery or error" + }, + "auth": { + "type": "object", + "title": "Auth mechanisms", + "description": "Define which auth mechanism the Web-Hook should use", + "oneOf": [ + { + "$ref": "#/definitions/webHookAuthApiKeyProperties" + }, + { + "$ref": "#/definitions/webHookAuthBasicAuthProperties" + } + ] + }, + "additionalProperties": false + } + } + } + }, "before": { "$ref": "#/definitions/selfServiceBeforeLogin" }, @@ -1473,9 +1382,7 @@ "description": "URL where the Ory Verify UI is hosted. This is the page where users activate and / or verify their email or telephone number. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": [ - "https://my-app.com/verify" - ], + "examples": ["https://my-app.com/verify"], "default": "https://www.ory.sh/kratos/docs/fallback/verification" }, "after": { @@ -1487,11 +1394,7 @@ "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] }, "before": { "$ref": "#/definitions/selfServiceBeforeVerification" @@ -1500,10 +1403,7 @@ "title": "Verification Strategy", "description": "The strategy to use for verification requests", "type": "string", - "enum": [ - "link", - "code" - ], + "enum": ["link", "code"], "default": "code" }, "notify_unknown_recipients": { @@ -1530,9 +1430,7 @@ "description": "URL where the Ory Recovery UI is hosted. This is the page where users request and complete account recovery. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": [ - "https://my-app.com/verify" - ], + "examples": ["https://my-app.com/verify"], "default": "https://www.ory.sh/kratos/docs/fallback/recovery" }, "after": { @@ -1544,11 +1442,7 @@ "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] }, "before": { "$ref": "#/definitions/selfServiceBeforeRecovery" @@ -1557,10 +1451,7 @@ "title": "Recovery Strategy", "description": "The strategy to use for recovery requests", "type": "string", - "enum": [ - "link", - "code" - ], + "enum": ["link", "code"], "default": "code" }, "notify_unknown_recipients": { @@ -1580,9 +1471,7 @@ "description": "URL where the Ory Kratos Error UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": [ - "https://my-app.com/kratos-error" - ], + "examples": ["https://my-app.com/kratos-error"], "default": "https://www.ory.sh/kratos/docs/fallback/error" } } @@ -1611,25 +1500,19 @@ "type": "string", "description": "The ID of the organization.", "format": "uuid", - "examples": [ - "00000000-0000-0000-0000-000000000000" - ] + "examples": ["00000000-0000-0000-0000-000000000000"] }, "label": { "type": "string", "description": "The label of the organization.", - "examples": [ - "ACME SSO" - ] + "examples": ["ACME SSO"] }, "domains": { "type": "array", "items": { "type": "string", "format": "hostname", - "examples": [ - "my-app.com" - ], + "examples": ["my-app.com"], "description": "If this domain matches the email's domain, this provider is shown." } } @@ -1669,20 +1552,14 @@ "base_url": { "title": "Override the base URL which should be used as the base for recovery and verification links.", "type": "string", - "examples": [ - "https://my-app.com" - ] + "examples": ["https://my-app.com"] }, "lifespan": { "title": "How long a link is valid for", "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] } } } @@ -1756,11 +1633,7 @@ "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] } } } @@ -1883,17 +1756,13 @@ "type": "string", "title": "Relying Party Display Name", "description": "An name to help the user identify this RP.", - "examples": [ - "Ory Foundation" - ] + "examples": ["Ory Foundation"] }, "id": { "type": "string", "title": "Relying Party Identifier", "description": "The id must be a subset of the domain currently in the browser.", - "examples": [ - "ory.sh" - ] + "examples": ["ory.sh"] }, "origin": { "type": "string", @@ -1901,9 +1770,7 @@ "description": "An explicit RP origin. If left empty, this defaults to `id`, prepended with the current protocol schema (HTTP or HTTPS).", "format": "uri", "deprecationMessage": "This field is deprecated. Use `origins` instead.", - "examples": [ - "https://www.ory.sh" - ] + "examples": ["https://www.ory.sh"] }, "origins": { "type": "array", @@ -1924,18 +1791,13 @@ "description": "An icon to help the user identify this RP.", "format": "uri", "deprecationMessage": "This field is deprecated and ignored due to security considerations.", - "examples": [ - "https://www.ory.sh/an-icon.png" - ] + "examples": ["https://www.ory.sh/an-icon.png"] } }, "type": "object", "oneOf": [ { - "required": [ - "id", - "display_name" - ], + "required": ["id", "display_name"], "properties": { "origin": { "not": {} @@ -1946,11 +1808,7 @@ } }, { - "required": [ - "id", - "display_name", - "origin" - ], + "required": ["id", "display_name", "origin"], "properties": { "origin": { "type": "string" @@ -1961,11 +1819,7 @@ } }, { - "required": [ - "id", - "display_name", - "origins" - ], + "required": ["id", "display_name", "origins"], "properties": { "origin": { "not": {} @@ -1990,14 +1844,10 @@ "const": true } }, - "required": [ - "enabled" - ] + "required": ["enabled"] }, "then": { - "required": [ - "config" - ] + "required": ["config"] } }, "passkey": { @@ -2020,17 +1870,13 @@ "type": "string", "title": "Relying Party Display Name", "description": "A name to help the user identify this RP.", - "examples": [ - "Ory Foundation" - ] + "examples": ["Ory Foundation"] }, "id": { "type": "string", "title": "Relying Party Identifier", "description": "The id must be a subset of the domain currently in the browser.", - "examples": [ - "ory.sh" - ] + "examples": ["ory.sh"] }, "origins": { "type": "array", @@ -2047,10 +1893,7 @@ } }, "type": "object", - "required": [ - "display_name", - "id" - ] + "required": ["display_name", "id"] } }, "additionalProperties": false @@ -2062,14 +1905,10 @@ "const": true } }, - "required": [ - "enabled" - ] + "required": ["enabled"] }, "then": { - "required": [ - "config" - ] + "required": ["config"] } }, "oidc": { @@ -2092,9 +1931,7 @@ "title": "Base URL for OAuth2 Redirect URIs", "description": "Can be used to modify the base URL for OAuth2 Redirect URLs. If unset, the Public Base URL will be used.", "format": "uri", - "examples": [ - "https://auth.myexample.org/" - ] + "examples": ["https://auth.myexample.org/"] }, "providers": { "title": "OpenID Connect and OAuth2 Providers", @@ -2199,9 +2036,7 @@ "$ref": "#/definitions/emailCourierTemplate" } }, - "required": [ - "email" - ] + "required": ["email"] } } }, @@ -2220,9 +2055,7 @@ "$ref": "#/definitions/smsCourierTemplate" } }, - "required": [ - "email" - ] + "required": ["email"] } } } @@ -2232,18 +2065,13 @@ "type": "string", "title": "Override message templates", "description": "You can override certain or all message templates by pointing this key to the path where the templates are located.", - "examples": [ - "/conf/courier-templates" - ] + "examples": ["/conf/courier-templates"] }, "message_retries": { "description": "Defines the maximum number of times the sending of a message is retried after it failed before it is marked as abandoned", "type": "integer", "default": 5, - "examples": [ - 10, - 60 - ] + "examples": [10, 60] }, "worker": { "description": "Configures the dispatch worker.", @@ -2266,10 +2094,7 @@ "title": "Delivery Strategy", "description": "Defines how emails will be sent, either through SMTP (default) or HTTP.", "type": "string", - "enum": [ - "smtp", - "http" - ], + "enum": ["smtp", "http"], "default": "smtp" }, "http": { @@ -2326,9 +2151,7 @@ "title": "SMTP Sender Name", "description": "The recipient of an email will see this as the sender name.", "type": "string", - "examples": [ - "Bob" - ] + "examples": ["Bob"] }, "headers": { "title": "SMTP Headers", @@ -2376,9 +2199,7 @@ "url": { "title": "HTTP address of API endpoint", "description": "This URL will be used to connect to the SMS provider.", - "examples": [ - "https://api.twillio.com/sms/send" - ], + "examples": ["https://api.twillio.com/sms/send"], "type": "string", "pattern": "^https?:\\/\\/.*" }, @@ -2420,10 +2241,7 @@ }, "additionalProperties": false }, - "required": [ - "url", - "method" - ], + "required": ["url", "method"], "additionalProperties": false } }, @@ -2440,26 +2258,19 @@ "title": "Channel id", "description": "The channel id. Corresponds to the .via property of the identity schema for recovery, verification, etc. Currently only phone is supported.", "maxLength": 32, - "enum": [ - "sms" - ] + "enum": ["sms"] }, "type": { "type": "string", "title": "Channel type", "description": "The channel type. Currently only http is supported.", - "enum": [ - "http" - ] + "enum": ["http"] }, "request_config": { "$ref": "#/definitions/httpRequestConfig" } }, - "required": [ - "id", - "request_config" - ], + "required": ["id", "request_config"], "additionalProperties": false } } @@ -2510,10 +2321,7 @@ "type": "string", "title": "Default Read Consistency Level", "description": "The default consistency level to use when reading from the database. Defaults to `strong` to not break existing API contracts. Only set this to `eventual` if you can accept that other read APIs will suddenly return eventually consistent results. It is only effective in Ory Network.", - "enum": [ - "strong", - "eventual" - ], + "enum": ["strong", "eventual"], "default": "strong" } } @@ -2541,9 +2349,7 @@ "description": "The URL where the admin endpoint is exposed at.", "type": "string", "format": "uri", - "examples": [ - "https://kratos.private-network:4434/" - ] + "examples": ["https://kratos.private-network:4434/"] }, "host": { "title": "Admin Host", @@ -2557,9 +2363,7 @@ "type": "integer", "minimum": 1, "maximum": 65535, - "examples": [ - 4434 - ], + "examples": [4434], "default": 4434 }, "socket": { @@ -2618,9 +2422,7 @@ ] }, "uniqueItems": true, - "default": [ - "*" - ], + "default": ["*"], "examples": [ [ "https://example.com", @@ -2632,13 +2434,7 @@ "allowed_methods": { "type": "array", "description": "A list of HTTP methods the user agent is allowed to use with cross-domain requests.", - "default": [ - "POST", - "GET", - "PUT", - "PATCH", - "DELETE" - ], + "default": ["POST", "GET", "PUT", "PATCH", "DELETE"], "items": { "type": "string", "enum": [ @@ -2672,9 +2468,7 @@ "exposed_headers": { "type": "array", "description": "Sets which headers are safe to expose to the API of a CORS API specification.", - "default": [ - "Content-Type" - ], + "default": ["Content-Type"], "items": { "type": "string" } @@ -2717,9 +2511,7 @@ "type": "integer", "minimum": 1, "maximum": 65535, - "examples": [ - 4433 - ], + "examples": [4433], "default": 4433 }, "socket": { @@ -2769,10 +2561,7 @@ "format": { "description": "The log format can either be text or JSON.", "type": "string", - "enum": [ - "json", - "text" - ] + "enum": ["json", "text"] } }, "additionalProperties": false @@ -2813,9 +2602,7 @@ "id": { "title": "The schema's ID.", "type": "string", - "examples": [ - "employee" - ] + "examples": ["employee"] }, "url": { "type": "string", @@ -2829,16 +2616,11 @@ ] } }, - "required": [ - "id", - "url" - ] + "required": ["id", "url"] } } }, - "required": [ - "schemas" - ], + "required": ["schemas"], "additionalProperties": false }, "secrets": { @@ -2887,10 +2669,7 @@ "description": "One of the values: argon2, bcrypt.\nAny other hashes will be migrated to the set algorithm once an identity authenticates using their password.", "type": "string", "default": "bcrypt", - "enum": [ - "argon2", - "bcrypt" - ] + "enum": ["argon2", "bcrypt"] }, "argon2": { "title": "Configuration for the Argon2id hasher.", @@ -2946,9 +2725,7 @@ "title": "Configuration for the Bcrypt hasher. Minimum is 4 when --dev flag is used and 12 otherwise.", "type": "object", "additionalProperties": false, - "required": [ - "cost" - ], + "required": ["cost"], "properties": { "cost": { "type": "integer", @@ -2970,11 +2747,7 @@ "description": "One of the values: noop, aes, xchacha20-poly1305", "type": "string", "default": "noop", - "enum": [ - "noop", - "aes", - "xchacha20-poly1305" - ] + "enum": ["noop", "aes", "xchacha20-poly1305"] } } }, @@ -2998,11 +2771,7 @@ "title": "HTTP Cookie Same Site Configuration", "description": "Sets the session and CSRF cookie SameSite.", "type": "string", - "enum": [ - "Strict", - "Lax", - "None" - ], + "enum": ["Strict", "Lax", "None"], "default": "Lax" } }, @@ -3032,9 +2801,7 @@ "patternProperties": { "[a-zA-Z0-9-_.]+": { "type": "object", - "required": [ - "jwks_url" - ], + "required": ["jwks_url"], "properties": { "ttl": { "type": "string", @@ -3067,11 +2834,7 @@ "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "24h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] }, "cookie": { "type": "object", @@ -3102,11 +2865,7 @@ "title": "Session Cookie SameSite Configuration", "description": "Sets the session cookie SameSite. Overrides `cookies.same_site`.", "type": "string", - "enum": [ - "Strict", - "Lax", - "None" - ] + "enum": ["Strict", "Lax", "None"] } }, "additionalProperties": false @@ -3116,11 +2875,7 @@ "description": "Sets when a session can be extended. Settings this value to `24h` will prevent the session from being extended before until 24 hours before it expires. This setting prevents excessive writes to the database. We highly recommend setting this value.", "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] } } }, @@ -3129,9 +2884,7 @@ "description": "SemVer according to https://semver.org/ prefixed with `v` as in our releases.", "type": "string", "pattern": "^(v(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?)|$", - "examples": [ - "v0.5.0-alpha.1" - ] + "examples": ["v0.5.0-alpha.1"] }, "dev": { "type": "boolean" @@ -3155,9 +2908,7 @@ "type": "integer", "minimum": 0, "maximum": 65535, - "examples": [ - 4434 - ], + "examples": [4434], "default": 0 }, "config": { @@ -3263,14 +3014,10 @@ "const": true } }, - "required": [ - "enabled" - ] + "required": ["enabled"] } }, - "required": [ - "verification" - ] + "required": ["verification"] }, { "properties": { @@ -3280,31 +3027,21 @@ "const": true } }, - "required": [ - "enabled" - ] + "required": ["enabled"] } }, - "required": [ - "recovery" - ] + "required": ["recovery"] } ] } }, - "required": [ - "flows" - ] + "required": ["flows"] } }, - "required": [ - "selfservice" - ] + "required": ["selfservice"] }, "then": { - "required": [ - "courier" - ] + "required": ["courier"] } }, { @@ -3323,33 +3060,21 @@ ] } }, - "required": [ - "algorithm" - ] + "required": ["algorithm"] } }, - "required": [ - "ciphers" - ] + "required": ["ciphers"] }, "then": { - "required": [ - "secrets" - ], + "required": ["secrets"], "properties": { "secrets": { - "required": [ - "cipher" - ] + "required": ["cipher"] } } } } ], - "required": [ - "identity", - "dsn", - "selfservice" - ], + "required": ["identity", "dsn", "selfservice"], "additionalProperties": false } diff --git a/identity/credentials_password.go b/identity/credentials_password.go index 85f5ac1d7d0c..4a6e7ebc7144 100644 --- a/identity/credentials_password.go +++ b/identity/credentials_password.go @@ -9,4 +9,13 @@ package identity type CredentialsPassword struct { // HashedPassword is a hash-representation of the password. HashedPassword string `json:"hashed_password"` + + // UsePasswordMigrationHook is set to true if the password should be migrated + // using the password migration hook. If set, and the HashedPassword is empty, a + // webhook will be called during login to migrate the password. + UsePasswordMigrationHook bool `json:"use_password_migration_hook,omitempty"` +} + +func (cp *CredentialsPassword) ShouldUsePasswordMigrationHook() bool { + return cp != nil && cp.HashedPassword == "" && cp.UsePasswordMigrationHook } diff --git a/identity/credentials_password_test.go b/identity/credentials_password_test.go new file mode 100644 index 000000000000..6e62720779e6 --- /dev/null +++ b/identity/credentials_password_test.go @@ -0,0 +1,46 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package identity + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCredentialsPassword_ShouldUsePasswordMigrationHook(t *testing.T) { + tests := []struct { + name string + cp *CredentialsPassword + want bool + }{{ + name: "pw set", + cp: &CredentialsPassword{ + HashedPassword: "pw", + UsePasswordMigrationHook: true, + }, + want: false, + }, { + name: "pw not set", + cp: &CredentialsPassword{ + HashedPassword: "", + UsePasswordMigrationHook: true, + }, + want: true, + }, { + name: "nil", + want: false, + }, { + name: "pw not set, hook not set", + cp: &CredentialsPassword{ + HashedPassword: "", + }, + want: false, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, tt.cp.ShouldUsePasswordMigrationHook(), "ShouldUsePasswordMigrationHook()") + }) + } +} diff --git a/internal/client-go/go.sum b/internal/client-go/go.sum index c966c8ddfd0d..6cc3f5911d11 100644 --- a/internal/client-go/go.sum +++ b/internal/client-go/go.sum @@ -4,6 +4,7 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e h1:bRhVy7zSSasaqNksaRZiA5EEI+Ei4I1nO5Jh72wfHlg= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/selfservice/hook/password_migration_hook.go b/selfservice/hook/password_migration_hook.go new file mode 100644 index 000000000000..065dc5dcddc6 --- /dev/null +++ b/selfservice/hook/password_migration_hook.go @@ -0,0 +1,111 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package hook + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/pkg/errors" + "github.com/tidwall/gjson" + "go.opentelemetry.io/otel/codes" + semconv "go.opentelemetry.io/otel/semconv/v1.11.0" + "go.opentelemetry.io/otel/trace" + grpccodes "google.golang.org/grpc/codes" + + "github.com/ory/herodot" + "github.com/ory/kratos/request" + "github.com/ory/kratos/schema" + "github.com/ory/x/otelx" +) + +type ( + PasswordMigration struct { + deps webHookDependencies + conf json.RawMessage + } + PasswordMigrationRequest struct { + Identifier string `json:"identifier"` + Password string `json:"password"` + } + PasswordMigrationResponse struct { + Status string `json:"status"` + } +) + +func NewPasswordMigrationHook(deps webHookDependencies, conf json.RawMessage) *PasswordMigration { + return &PasswordMigration{deps: deps, conf: conf} +} + +func (p *PasswordMigration) Execute(ctx context.Context, data *PasswordMigrationRequest) (err error) { + var ( + httpClient = p.deps.HTTPClient(ctx) + emitEvent = gjson.GetBytes(p.conf, "emit_analytics_event").Bool() || !gjson.GetBytes(p.conf, "emit_analytics_event").Exists() // default true + tracer = trace.SpanFromContext(ctx).TracerProvider().Tracer("kratos-webhooks") + ) + + ctx, span := tracer.Start(ctx, "selfservice.login.password_migration") + defer otelx.End(span, &err) + + if emitEvent { + instrumentHTTPClientForEvents(ctx, httpClient) + } + builder, err := request.NewBuilder(ctx, p.conf, p.deps, nil) + if err != nil { + return errors.WithStack(err) + } + req, err := builder.BuildRequest(ctx, nil) // passing a nil body here skips Jsonnet + if err != nil { + return errors.WithStack(err) + } + rawData, err := json.Marshal(data) + if err != nil { + return errors.WithStack(err) + } + if err = req.SetBody(rawData); err != nil { + return errors.WithStack(err) + } + + p.deps.Logger().WithRequest(req.Request).Info("Dispatching password migration hook") + req = req.WithContext(ctx) + + resp, err := httpClient.Do(req) + if err != nil { + return herodot.DefaultError{ + CodeField: http.StatusBadGateway, + StatusField: http.StatusText(http.StatusBadGateway), + GRPCCodeField: grpccodes.Aborted, + ReasonField: "A third-party upstream service could not be reached. Please try again later.", + ErrorField: "calling the password migration hook failed", + }.WithWrap(errors.WithStack(err)) + } + defer resp.Body.Close() + span.SetAttributes(semconv.HTTPAttributesFromHTTPStatusCode(resp.StatusCode)...) + + switch resp.StatusCode { + case http.StatusOK: + // We now check if the response matches `{"status": "password_match" }`. + dec := json.NewDecoder(io.LimitReader(resp.Body, 1024)) // limit the response body to 1KB + var response PasswordMigrationResponse + if err := dec.Decode(&response); err != nil || response.Status != "password_match" { + return errors.WithStack(schema.NewInvalidCredentialsError()) + } + return nil + + case http.StatusForbidden: + return errors.WithStack(schema.NewInvalidCredentialsError()) + default: + span.SetStatus(codes.Error, "Unexpected HTTP status code") + return herodot.DefaultError{ + CodeField: http.StatusBadGateway, + StatusField: http.StatusText(http.StatusBadGateway), + GRPCCodeField: grpccodes.Aborted, + ReasonField: "A third-party upstream service responded improperly. Please try again later.", + ErrorField: fmt.Sprintf("password migration hook failed with status code %v", resp.StatusCode), + } + } +} diff --git a/selfservice/strategy/password/login.go b/selfservice/strategy/password/login.go index 8c91d7e6c4f9..3600e29b4e0e 100644 --- a/selfservice/strategy/password/login.go +++ b/selfservice/strategy/password/login.go @@ -10,7 +10,9 @@ import ( "net/http" "time" + "github.com/ory/kratos/hash" "github.com/ory/kratos/selfservice/flowhelpers" + "github.com/ory/kratos/selfservice/hook" "github.com/ory/kratos/session" "github.com/ory/x/stringsx" @@ -22,7 +24,6 @@ import ( "github.com/ory/herodot" "github.com/ory/x/decoderx" - "github.com/ory/kratos/hash" "github.com/ory/kratos/identity" "github.com/ory/kratos/schema" "github.com/ory/kratos/selfservice/flow" @@ -69,7 +70,8 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, return nil, s.handleLoginError(w, r, f, &p, err) } - i, c, err := s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(r.Context(), s.ID(), stringsx.Coalesce(p.Identifier, p.LegacyIdentifier)) + identifier := stringsx.Coalesce(p.Identifier, p.LegacyIdentifier) + i, c, err := s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(r.Context(), s.ID(), identifier) if err != nil { time.Sleep(x.RandomDelay(s.d.Config().HasherArgon2(r.Context()).ExpectedDuration, s.d.Config().HasherArgon2(r.Context()).ExpectedDeviation)) return nil, s.handleLoginError(w, r, f, &p, errors.WithStack(schema.NewInvalidCredentialsError())) @@ -81,17 +83,33 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, return nil, herodot.ErrInternalServerError.WithReason("The password credentials could not be decoded properly").WithDebug(err.Error()).WithWrap(err) } - if err := hash.Compare(r.Context(), []byte(p.Password), []byte(o.HashedPassword)); err != nil { - return nil, s.handleLoginError(w, r, f, &p, errors.WithStack(schema.NewInvalidCredentialsError())) - } + if o.ShouldUsePasswordMigrationHook() { + pwHook := s.d.Config().PasswordMigrationHook(r.Context()) + if !pwHook.Enabled { + return nil, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Password migration hook is not enabled but password migration is requested.")) + } + + migrationHook := hook.NewPasswordMigrationHook(s.d, pwHook.Config) + err = migrationHook.Execute(r.Context(), &hook.PasswordMigrationRequest{Identifier: identifier, Password: p.Password}) + if err != nil { + return nil, s.handleLoginError(w, r, f, &p, err) + } - if !s.d.Hasher(r.Context()).Understands([]byte(o.HashedPassword)) { if err := s.migratePasswordHash(r.Context(), i.ID, []byte(p.Password)); err != nil { return nil, s.handleLoginError(w, r, f, &p, err) } + } else { + if err := hash.Compare(r.Context(), []byte(p.Password), []byte(o.HashedPassword)); err != nil { + return nil, s.handleLoginError(w, r, f, &p, errors.WithStack(schema.NewInvalidCredentialsError())) + } + + if !s.d.Hasher(r.Context()).Understands([]byte(o.HashedPassword)) { + if err := s.migratePasswordHash(r.Context(), i.ID, []byte(p.Password)); err != nil { + return nil, s.handleLoginError(w, r, f, &p, err) + } + } } - f.Active = identity.CredentialsTypePassword f.Active = s.ID() if err = s.d.LoginFlowPersister().UpdateLoginFlow(r.Context(), f); err != nil { return nil, s.handleLoginError(w, r, f, &p, errors.WithStack(herodot.ErrInternalServerError.WithReason("Could not update flow").WithDebug(err.Error()))) diff --git a/selfservice/strategy/password/login_test.go b/selfservice/strategy/password/login_test.go index 8d8879cce91c..8c2f2cb73245 100644 --- a/selfservice/strategy/password/login_test.go +++ b/selfservice/strategy/password/login_test.go @@ -16,34 +16,30 @@ import ( "testing" "time" - "github.com/ory/kratos/driver" - "github.com/ory/kratos/internal/registrationhelpers" - - "github.com/ory/kratos/selfservice/flow" - + "github.com/gobuffalo/httptest" "github.com/gofrs/uuid" - - "github.com/ory/x/urlx" - - "github.com/ory/kratos/hash" - kratos "github.com/ory/kratos/internal/httpclient" - "github.com/ory/x/assertx" - "github.com/ory/x/errorsx" - "github.com/ory/x/ioutilx" - "github.com/ory/x/sqlxx" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" + "github.com/ory/kratos/driver" "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/hash" "github.com/ory/kratos/identity" "github.com/ory/kratos/internal" + kratos "github.com/ory/kratos/internal/httpclient" + "github.com/ory/kratos/internal/registrationhelpers" "github.com/ory/kratos/internal/testhelpers" "github.com/ory/kratos/schema" + "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/login" "github.com/ory/kratos/text" "github.com/ory/kratos/x" + "github.com/ory/x/assertx" + "github.com/ory/x/errorsx" + "github.com/ory/x/ioutilx" + "github.com/ory/x/sqlxx" + "github.com/ory/x/urlx" ) //go:embed stub/login.schema.json @@ -864,4 +860,207 @@ func TestCompleteLogin(t *testing.T) { false, true, http.StatusOK, redirTS.URL) assert.Equal(t, identifier, gjson.Get(body, "identity.traits.subject").String(), "%s", body) }) + + t.Run("suite=password migration hook", func(t *testing.T) { + ctx := context.Background() + + type ( + hookPayload = struct { + Identifier string `json:"identifier"` + Password string `json:"password"` + } + tsRequestHandler = func(hookPayload) (status int, body string) + ) + returnStatus := func(status int) func(string, string) tsRequestHandler { + return func(string, string) tsRequestHandler { + return func(hookPayload) (int, string) { return status, "" } + } + } + returnStatic := func(status int, body string) func(string, string) tsRequestHandler { + return func(string, string) tsRequestHandler { + return func(hookPayload) (int, string) { return status, body } + } + } + + // each test case sends (number of expected calls) handlers to the channel, at a max of 3 + tsChan := make(chan tsRequestHandler, 3) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, err := io.ReadAll(r.Body) + require.NoError(t, err) + _ = r.Body.Close() + var payload hookPayload + require.NoError(t, json.Unmarshal(b, &payload)) + + select { + case handlerFn := <-tsChan: + status, body := handlerFn(payload) + w.WriteHeader(status) + _, _ = io.WriteString(w, body) + + default: + t.Fatal("unexpected call to the password migration hook") + } + })) + t.Cleanup(ts.Close) + + require.NoError(t, reg.Config().Set(ctx, config.ViperKeyPasswordMigrationHook, &config.PasswordMigrationHook{ + Enabled: true, + Config: json.RawMessage(fmt.Sprintf(`{"URL":"%s"}`, ts.URL)), + })) + + for _, tc := range []struct { + name string + hookHandler func(identifier, password string) tsRequestHandler + expectHookCalls int + setupFn func() func() + credentialsConfig string + expectSuccess bool + }{{ + name: "should call migration hook", + credentialsConfig: `{"use_password_migration_hook": true}`, + hookHandler: func(identifier, password string) tsRequestHandler { + return func(payload hookPayload) (status int, body string) { + if payload.Identifier == identifier && payload.Password == password { + return http.StatusOK, `{"status":"password_match"}` + } else { + return http.StatusOK, `{"status":"no_match"}` + } + } + }, + expectHookCalls: 1, + expectSuccess: true, + }, { + name: "should not update identity when the password is wrong", + credentialsConfig: `{"use_password_migration_hook": true}`, + hookHandler: returnStatus(http.StatusForbidden), + expectHookCalls: 1, + expectSuccess: false, + }, { + name: "should inspect response", + credentialsConfig: `{"use_password_migration_hook": true}`, + hookHandler: returnStatic(http.StatusOK, `{"status":"password_no_match"}`), + expectHookCalls: 1, + expectSuccess: false, + }, { + name: "should not update identity when the migration hook returns 200 without JSON", + credentialsConfig: `{"use_password_migration_hook": true}`, + hookHandler: returnStatus(http.StatusOK), + expectHookCalls: 1, + expectSuccess: false, + }, { + name: "should not update identity when the migration hook returns 500", + credentialsConfig: `{"use_password_migration_hook": true}`, + hookHandler: returnStatus(http.StatusInternalServerError), + expectHookCalls: 3, // expect retries on 500 + expectSuccess: false, + }, { + name: "should not update identity when the migration hook returns 201", + credentialsConfig: `{"use_password_migration_hook": true}`, + hookHandler: returnStatic(http.StatusCreated, `{"status":"password_match"}`), + expectHookCalls: 1, + expectSuccess: false, + }, { + name: "should not update identity and not call hook when hash is set", + credentialsConfig: `{"use_password_migration_hook": true, "hashed_password":"hash"}`, + expectSuccess: false, + }, { + name: "should not update identity and not call hook when use_password_migration_hook is not set", + credentialsConfig: `{"hashed_password":"hash"}`, + expectSuccess: false, + }, { + name: "should not update identity and not call hook when credential is empty", + credentialsConfig: `{}`, + expectSuccess: false, + }, { + name: "should not call migration hook if disabled", + credentialsConfig: `{"use_password_migration_hook": true}`, + setupFn: func() func() { + require.NoError(t, reg.Config().Set(ctx, config.ViperKeyPasswordMigrationHook+".enabled", false)) + return func() { + require.NoError(t, reg.Config().Set(ctx, config.ViperKeyPasswordMigrationHook+".enabled", true)) + } + }, + expectSuccess: false, + }} { + + t.Run("case="+tc.name, func(t *testing.T) { + if tc.setupFn != nil { + cleanup := tc.setupFn() + t.Cleanup(cleanup) + } + + identifier := x.NewUUID().String() + password := x.NewUUID().String() + iId := x.NewUUID() + require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, &identity.Identity{ + ID: iId, + Traits: identity.Traits(fmt.Sprintf(`{"subject":"%s"}`, identifier)), + Credentials: map[identity.CredentialsType]identity.Credentials{ + identity.CredentialsTypePassword: { + Type: identity.CredentialsTypePassword, + Identifiers: []string{identifier}, + Config: sqlxx.JSONRawMessage(tc.credentialsConfig), + }, + }, + VerifiableAddresses: []identity.VerifiableAddress{ + { + ID: x.NewUUID(), + Value: identifier, + Verified: true, + CreatedAt: time.Now(), + IdentityID: iId, + }, + }, + })) + + values := func(v url.Values) { + v.Set("identifier", identifier) + v.Set("method", identity.CredentialsTypePassword.String()) + v.Set("password", password) + } + + for range tc.expectHookCalls { + tsChan <- tc.hookHandler(identifier, password) + } + + browserClient := testhelpers.NewClientWithCookies(t) + + if tc.expectSuccess { + body := testhelpers.SubmitLoginForm(t, false, browserClient, publicTS, values, + false, false, http.StatusOK, redirTS.URL) + assert.Equal(t, identifier, gjson.Get(body, "identity.traits.subject").String(), "%s", body) + + // check if password hash algorithm is upgraded + _, c, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, identifier) + require.NoError(t, err) + var o identity.CredentialsPassword + require.NoError(t, json.NewDecoder(bytes.NewBuffer(c.Config)).Decode(&o)) + assert.True(t, reg.Hasher(ctx).Understands([]byte(o.HashedPassword)), "%s", o.HashedPassword) + assert.True(t, hash.IsBcryptHash([]byte(o.HashedPassword)), "%s", o.HashedPassword) + + // retry after upgraded + body = testhelpers.SubmitLoginForm(t, false, browserClient, publicTS, values, + false, true, http.StatusOK, redirTS.URL) + assert.Equal(t, identifier, gjson.Get(body, "identity.traits.subject").String(), "%s", body) + } else { + body := testhelpers.SubmitLoginForm(t, false, browserClient, publicTS, values, + false, false, http.StatusOK, "") + assert.Empty(t, gjson.Get(body, "identity.traits.subject").String(), "%s", body) + // Check that the config did not change + _, c, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(context.Background(), identity.CredentialsTypePassword, identifier) + require.NoError(t, err) + assert.JSONEq(t, tc.credentialsConfig, string(c.Config)) + } + + // expect all hook calls to be done + select { + case <-tsChan: + t.Fatal("the test unexpectedly did too few calls to the password hook") + default: + // pass + } + }) + } + }) } diff --git a/selfservice/strategy/password/strategy.go b/selfservice/strategy/password/strategy.go index 911ad619cd15..ae57982dd89f 100644 --- a/selfservice/strategy/password/strategy.go +++ b/selfservice/strategy/password/strategy.go @@ -7,11 +7,12 @@ import ( "context" "encoding/json" - "github.com/ory/kratos/ui/node" - "github.com/go-playground/validator/v10" "github.com/pkg/errors" + "github.com/ory/kratos/ui/node" + "github.com/ory/x/jsonnetsecure" + "github.com/ory/x/decoderx" "github.com/ory/kratos/continuity" @@ -37,9 +38,10 @@ type registrationStrategyDependencies interface { x.WriterProvider x.CSRFTokenGeneratorProvider x.CSRFProvider - + x.HTTPClientProvider + x.TracingProvider + jsonnetsecure.VMProvider config.Provider - continuity.ManagementProvider errorx.ManagementProvider