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:
24
selfservice/strategy/lookup/.schema/login.schema.json
Normal file
24
selfservice/strategy/lookup/.schema/login.schema.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$id": "https://schemas.ory.sh/kratos/selfservice/strategy/lookup/login.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"lookup_secret",
|
||||
"method"
|
||||
],
|
||||
"properties": {
|
||||
"csrf_token": {
|
||||
"type": "string"
|
||||
},
|
||||
"method": {
|
||||
"type": "string"
|
||||
},
|
||||
"lookup_secret": {
|
||||
"type": "string"
|
||||
},
|
||||
"transient_payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
29
selfservice/strategy/lookup/.schema/settings.schema.json
Normal file
29
selfservice/strategy/lookup/.schema/settings.schema.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$id": "https://schemas.ory.sh/kratos/selfservice/strategy/lookup/settings.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"csrf_token": {
|
||||
"type": "string"
|
||||
},
|
||||
"method": {
|
||||
"type": "string"
|
||||
},
|
||||
"lookup_secret_disable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"lookup_secret_reveal": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"lookup_secret_regenerate": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"lookup_secret_confirm": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"transient_payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
[
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "csrf_token",
|
||||
"node_type": "input",
|
||||
"required": true,
|
||||
"type": "hidden"
|
||||
},
|
||||
"group": "default",
|
||||
"messages": [],
|
||||
"meta": {},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "lookup_secret",
|
||||
"node_type": "input",
|
||||
"required": true,
|
||||
"type": "text",
|
||||
"value": ""
|
||||
},
|
||||
"group": "lookup_secret",
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1010007,
|
||||
"text": "Backup recovery code",
|
||||
"type": "info"
|
||||
}
|
||||
},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "method",
|
||||
"node_type": "input",
|
||||
"type": "submit",
|
||||
"value": "lookup_secret"
|
||||
},
|
||||
"group": "lookup_secret",
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1010010,
|
||||
"text": "Use backup recovery code",
|
||||
"type": "info"
|
||||
}
|
||||
},
|
||||
"type": "input"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
[
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "csrf_token",
|
||||
"node_type": "input",
|
||||
"required": true,
|
||||
"type": "hidden"
|
||||
},
|
||||
"group": "default",
|
||||
"messages": [],
|
||||
"meta": {},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "lookup_secret_regenerate",
|
||||
"node_type": "input",
|
||||
"type": "submit",
|
||||
"value": "true"
|
||||
},
|
||||
"group": "lookup_secret",
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050008,
|
||||
"text": "Generate new backup recovery codes",
|
||||
"type": "info"
|
||||
}
|
||||
},
|
||||
"type": "input"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
[
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "csrf_token",
|
||||
"node_type": "input",
|
||||
"required": true,
|
||||
"type": "hidden"
|
||||
},
|
||||
"group": "default",
|
||||
"messages": [],
|
||||
"meta": {},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "lookup_secret_regenerate",
|
||||
"node_type": "input",
|
||||
"type": "submit",
|
||||
"value": "true"
|
||||
},
|
||||
"group": "lookup_secret",
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050008,
|
||||
"text": "Generate new backup recovery codes",
|
||||
"type": "info"
|
||||
}
|
||||
},
|
||||
"type": "input"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
[
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "csrf_token",
|
||||
"node_type": "input",
|
||||
"required": true,
|
||||
"type": "hidden"
|
||||
},
|
||||
"group": "default",
|
||||
"messages": [],
|
||||
"meta": {},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "lookup_secret_regenerate",
|
||||
"node_type": "input",
|
||||
"type": "submit",
|
||||
"value": "true"
|
||||
},
|
||||
"group": "lookup_secret",
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050008,
|
||||
"text": "Generate new backup recovery codes",
|
||||
"type": "info"
|
||||
}
|
||||
},
|
||||
"type": "input"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
[
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "csrf_token",
|
||||
"node_type": "input",
|
||||
"required": true,
|
||||
"type": "hidden"
|
||||
},
|
||||
"group": "default",
|
||||
"messages": [],
|
||||
"meta": {},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "lookup_secret_reveal",
|
||||
"node_type": "input",
|
||||
"type": "submit",
|
||||
"value": "true"
|
||||
},
|
||||
"group": "lookup_secret",
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050007,
|
||||
"text": "Reveal backup recovery codes",
|
||||
"type": "info"
|
||||
}
|
||||
},
|
||||
"type": "input"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
[
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "csrf_token",
|
||||
"node_type": "input",
|
||||
"required": true,
|
||||
"type": "hidden"
|
||||
},
|
||||
"group": "default",
|
||||
"messages": [],
|
||||
"meta": {},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "lookup_secret_reveal",
|
||||
"node_type": "input",
|
||||
"type": "submit",
|
||||
"value": "true"
|
||||
},
|
||||
"group": "lookup_secret",
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050007,
|
||||
"text": "Reveal backup recovery codes",
|
||||
"type": "info"
|
||||
}
|
||||
},
|
||||
"type": "input"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
[
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "csrf_token",
|
||||
"node_type": "input",
|
||||
"required": true,
|
||||
"type": "hidden"
|
||||
},
|
||||
"group": "default",
|
||||
"messages": [],
|
||||
"meta": {},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "lookup_secret_reveal",
|
||||
"node_type": "input",
|
||||
"type": "submit",
|
||||
"value": "true"
|
||||
},
|
||||
"group": "lookup_secret",
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050007,
|
||||
"text": "Reveal backup recovery codes",
|
||||
"type": "info"
|
||||
}
|
||||
},
|
||||
"type": "input"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
[
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "csrf_token",
|
||||
"node_type": "input",
|
||||
"required": true,
|
||||
"type": "hidden"
|
||||
},
|
||||
"group": "default",
|
||||
"messages": [],
|
||||
"meta": {},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "lookup_secret_reveal",
|
||||
"node_type": "input",
|
||||
"type": "submit",
|
||||
"value": "true"
|
||||
},
|
||||
"group": "lookup_secret",
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050007,
|
||||
"text": "Reveal backup recovery codes",
|
||||
"type": "info"
|
||||
}
|
||||
},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "lookup_secret_disable",
|
||||
"node_type": "input",
|
||||
"type": "submit",
|
||||
"value": "true"
|
||||
},
|
||||
"group": "lookup_secret",
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050016,
|
||||
"text": "Disable this method",
|
||||
"type": "info"
|
||||
}
|
||||
},
|
||||
"type": "input"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
[
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "csrf_token",
|
||||
"node_type": "input",
|
||||
"required": true,
|
||||
"type": "hidden"
|
||||
},
|
||||
"group": "default",
|
||||
"messages": [],
|
||||
"meta": {},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "lookup_secret_reveal",
|
||||
"node_type": "input",
|
||||
"type": "submit",
|
||||
"value": "true"
|
||||
},
|
||||
"group": "lookup_secret",
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050007,
|
||||
"text": "Reveal backup recovery codes",
|
||||
"type": "info"
|
||||
}
|
||||
},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "lookup_secret_disable",
|
||||
"node_type": "input",
|
||||
"type": "submit",
|
||||
"value": "true"
|
||||
},
|
||||
"group": "lookup_secret",
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050016,
|
||||
"text": "Disable this method",
|
||||
"type": "info"
|
||||
}
|
||||
},
|
||||
"type": "input"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
[
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "csrf_token",
|
||||
"node_type": "input",
|
||||
"required": true,
|
||||
"type": "hidden"
|
||||
},
|
||||
"group": "default",
|
||||
"messages": [],
|
||||
"meta": {},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "lookup_secret_reveal",
|
||||
"node_type": "input",
|
||||
"type": "submit",
|
||||
"value": "true"
|
||||
},
|
||||
"group": "lookup_secret",
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050007,
|
||||
"text": "Reveal backup recovery codes",
|
||||
"type": "info"
|
||||
}
|
||||
},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "lookup_secret_disable",
|
||||
"node_type": "input",
|
||||
"type": "submit",
|
||||
"value": "true"
|
||||
},
|
||||
"group": "lookup_secret",
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050016,
|
||||
"text": "Disable this method",
|
||||
"type": "info"
|
||||
}
|
||||
},
|
||||
"type": "input"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,178 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"group": "lookup_secret",
|
||||
"attributes": {
|
||||
"text": {
|
||||
"id": 1050015,
|
||||
"text": "key-0, used, key-2, key-3, used, key-5, key-6, used, key-8, key-9, used, key-11",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secrets": [
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-0",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050014,
|
||||
"text": "Secret was used at 2021-08-17 11:32:39 +0000 UTC",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"used_at": "2021-08-17T11:32:39Z",
|
||||
"used_at_unix": 1629199959
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-2",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-3",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050014,
|
||||
"text": "Secret was used at 2021-08-17 11:32:42 +0000 UTC",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"used_at": "2021-08-17T11:32:42Z",
|
||||
"used_at_unix": 1629199962
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-5",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-6",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050014,
|
||||
"text": "Secret was used at 2021-08-17 11:32:45 +0000 UTC",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"used_at": "2021-08-17T11:32:45Z",
|
||||
"used_at_unix": 1629199965
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-8",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-9",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-9"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050014,
|
||||
"text": "Secret was used at 2021-08-17 11:32:48 +0000 UTC",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"used_at": "2021-08-17T11:32:48Z",
|
||||
"used_at_unix": 1629199968
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-11",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-11"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "lookup_secret_codes",
|
||||
"node_type": "text"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050010,
|
||||
"text": "These are your back up recovery codes. Please keep them in a safe place!",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "lookup_secret",
|
||||
"attributes": {
|
||||
"name": "lookup_secret_disable",
|
||||
"type": "submit",
|
||||
"value": "true",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050016,
|
||||
"text": "Disable this method",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "lookup_secret",
|
||||
"attributes": {
|
||||
"name": "lookup_secret_regenerate",
|
||||
"type": "submit",
|
||||
"value": "true",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050008,
|
||||
"text": "Generate new backup recovery codes",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,178 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"group": "lookup_secret",
|
||||
"attributes": {
|
||||
"text": {
|
||||
"id": 1050015,
|
||||
"text": "key-0, used, key-2, key-3, used, key-5, key-6, used, key-8, key-9, used, key-11",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secrets": [
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-0",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050014,
|
||||
"text": "Secret was used at 2021-08-17 11:32:39 +0000 UTC",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"used_at": "2021-08-17T11:32:39Z",
|
||||
"used_at_unix": 1629199959
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-2",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-3",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050014,
|
||||
"text": "Secret was used at 2021-08-17 11:32:42 +0000 UTC",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"used_at": "2021-08-17T11:32:42Z",
|
||||
"used_at_unix": 1629199962
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-5",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-6",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050014,
|
||||
"text": "Secret was used at 2021-08-17 11:32:45 +0000 UTC",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"used_at": "2021-08-17T11:32:45Z",
|
||||
"used_at_unix": 1629199965
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-8",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-9",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-9"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050014,
|
||||
"text": "Secret was used at 2021-08-17 11:32:48 +0000 UTC",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"used_at": "2021-08-17T11:32:48Z",
|
||||
"used_at_unix": 1629199968
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-11",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-11"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "lookup_secret_codes",
|
||||
"node_type": "text"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050010,
|
||||
"text": "These are your back up recovery codes. Please keep them in a safe place!",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "lookup_secret",
|
||||
"attributes": {
|
||||
"name": "lookup_secret_disable",
|
||||
"type": "submit",
|
||||
"value": "true",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050016,
|
||||
"text": "Disable this method",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "lookup_secret",
|
||||
"attributes": {
|
||||
"name": "lookup_secret_regenerate",
|
||||
"type": "submit",
|
||||
"value": "true",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050008,
|
||||
"text": "Generate new backup recovery codes",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,178 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"group": "lookup_secret",
|
||||
"attributes": {
|
||||
"text": {
|
||||
"id": 1050015,
|
||||
"text": "key-0, used, key-2, key-3, used, key-5, key-6, used, key-8, key-9, used, key-11",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secrets": [
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-0",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050014,
|
||||
"text": "Secret was used at 2021-08-17 11:32:39 +0000 UTC",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"used_at": "2021-08-17T11:32:39Z",
|
||||
"used_at_unix": 1629199959
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-2",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-3",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050014,
|
||||
"text": "Secret was used at 2021-08-17 11:32:42 +0000 UTC",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"used_at": "2021-08-17T11:32:42Z",
|
||||
"used_at_unix": 1629199962
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-5",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-6",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050014,
|
||||
"text": "Secret was used at 2021-08-17 11:32:45 +0000 UTC",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"used_at": "2021-08-17T11:32:45Z",
|
||||
"used_at_unix": 1629199965
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-8",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-9",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-9"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050014,
|
||||
"text": "Secret was used at 2021-08-17 11:32:48 +0000 UTC",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"used_at": "2021-08-17T11:32:48Z",
|
||||
"used_at_unix": 1629199968
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-11",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-11"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "lookup_secret_codes",
|
||||
"node_type": "text"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050010,
|
||||
"text": "These are your back up recovery codes. Please keep them in a safe place!",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "lookup_secret",
|
||||
"attributes": {
|
||||
"name": "lookup_secret_disable",
|
||||
"type": "submit",
|
||||
"value": "true",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050016,
|
||||
"text": "Disable this method",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "lookup_secret",
|
||||
"attributes": {
|
||||
"name": "lookup_secret_regenerate",
|
||||
"type": "submit",
|
||||
"value": "true",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050008,
|
||||
"text": "Generate new backup recovery codes",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,178 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"group": "lookup_secret",
|
||||
"attributes": {
|
||||
"text": {
|
||||
"id": 1050015,
|
||||
"text": "key-0, used, key-2, key-3, used, key-5, key-6, used, key-8, key-9, used, key-11",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secrets": [
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-0",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050014,
|
||||
"text": "Secret was used at 2021-08-17 11:32:39 +0000 UTC",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"used_at": "2021-08-17T11:32:39Z",
|
||||
"used_at_unix": 1629199959
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-2",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-3",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050014,
|
||||
"text": "Secret was used at 2021-08-17 11:32:42 +0000 UTC",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"used_at": "2021-08-17T11:32:42Z",
|
||||
"used_at_unix": 1629199962
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-5",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-6",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050014,
|
||||
"text": "Secret was used at 2021-08-17 11:32:45 +0000 UTC",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"used_at": "2021-08-17T11:32:45Z",
|
||||
"used_at_unix": 1629199965
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-8",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-9",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-9"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050014,
|
||||
"text": "Secret was used at 2021-08-17 11:32:48 +0000 UTC",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"used_at": "2021-08-17T11:32:48Z",
|
||||
"used_at_unix": 1629199968
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1050009,
|
||||
"text": "key-11",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"secret": "key-11"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "lookup_secret_codes",
|
||||
"node_type": "text"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050010,
|
||||
"text": "These are your back up recovery codes. Please keep them in a safe place!",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "lookup_secret",
|
||||
"attributes": {
|
||||
"name": "lookup_secret_disable",
|
||||
"type": "submit",
|
||||
"value": "true",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050016,
|
||||
"text": "Disable this method",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "lookup_secret",
|
||||
"attributes": {
|
||||
"name": "lookup_secret_regenerate",
|
||||
"type": "submit",
|
||||
"value": "true",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050008,
|
||||
"text": "Generate new backup recovery codes",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "lookup_secret",
|
||||
"attributes": {
|
||||
"name": "lookup_secret",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1010007,
|
||||
"text": "Backup recovery code",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "lookup_secret",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "lookup_secret",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1010010,
|
||||
"text": "Use backup recovery code",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "lookup_secret",
|
||||
"attributes": {
|
||||
"name": "lookup_secret",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1010007,
|
||||
"text": "Backup recovery code",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "lookup_secret",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "lookup_secret",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1010010,
|
||||
"text": "Use backup recovery code",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
171
selfservice/strategy/lookup/login.go
Normal file
171
selfservice/strategy/lookup/login.go
Normal file
@@ -0,0 +1,171 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package lookup
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/schema"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/selfservice/flow/login"
|
||||
"github.com/ory/kratos/session"
|
||||
"github.com/ory/kratos/text"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/x/decoderx"
|
||||
"github.com/ory/x/otelx"
|
||||
"github.com/ory/x/sqlcon"
|
||||
"github.com/ory/x/sqlxx"
|
||||
)
|
||||
|
||||
func (s *Strategy) PopulateLoginMethod(r *http.Request, requestedAAL identity.AuthenticatorAssuranceLevel, sr *login.Flow) error {
|
||||
// This strategy can only solve AAL2
|
||||
if requestedAAL != identity.AuthenticatorAssuranceLevel2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// We have done proper validation before so this should never error.
|
||||
// Only the identity ID is read below; the identity is re-fetched with its confidential
|
||||
// fields anyway, so skip expanding anything here.
|
||||
sess, err := s.d.SessionManager().FetchFromRequest(r.Context(), r, session.ExpandNothing, identity.ExpandNothing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
id, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(r.Context(), sess.IdentityID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, ok := id.GetCredentials(s.ID())
|
||||
if !ok {
|
||||
// Identity has no lookup codes
|
||||
return nil
|
||||
}
|
||||
|
||||
sr.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
||||
sr.UI.SetNode(node.NewInputField(node.LookupCodeEnter, "", node.LookupGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute).WithMetaLabel(text.NewInfoLoginLookupLabel()))
|
||||
sr.UI.GetNodes().Append(node.NewInputField("method", s.ID(), node.LookupGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoLoginLookup()))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Strategy) handleLoginError(r *http.Request, f *login.Flow, err error) error {
|
||||
if f != nil {
|
||||
f.UI.Nodes.ResetNodes(node.LookupCodeEnter)
|
||||
if f.Type == flow.TypeBrowser {
|
||||
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Update Login Flow with Lookup Secret Method
|
||||
//
|
||||
// swagger:model updateLoginFlowWithLookupSecretMethod
|
||||
type updateLoginFlowWithLookupSecretMethod struct {
|
||||
// Method should be set to "lookup_secret" when logging in using the lookup_secret strategy.
|
||||
//
|
||||
// required: true
|
||||
Method string `json:"method"`
|
||||
|
||||
// Sending the anti-csrf token is only required for browser login flows.
|
||||
CSRFToken string `json:"csrf_token"`
|
||||
|
||||
// The lookup secret.
|
||||
//
|
||||
// required: true
|
||||
Code string `json:"lookup_secret"`
|
||||
}
|
||||
|
||||
func (s *Strategy) Login(_ http.ResponseWriter, r *http.Request, f *login.Flow, sess *session.Session) (i *identity.Identity, err error) {
|
||||
ctx, span := s.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.strategy.lookup.Strategy.Login")
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
if err := login.CheckAAL(f, identity.AuthenticatorAssuranceLevel2); err != nil {
|
||||
span.SetAttributes(attribute.String("not_responsible_reason", "requested AAL is not AAL2"))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := flow.MethodEnabledAndAllowedFromRequest(r, f.GetFlowName(), s.ID().String(), s.d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var p updateLoginFlowWithLookupSecretMethod
|
||||
if err := decoderx.Decode(r, &p,
|
||||
decoderx.HTTPDecoderSetValidatePayloads(true),
|
||||
decoderx.MustHTTPRawJSONSchemaCompiler(loginSchema),
|
||||
decoderx.HTTPDecoderJSONFollowsFormFormat()); err != nil {
|
||||
return nil, s.handleLoginError(r, f, err)
|
||||
}
|
||||
|
||||
if err := flow.EnsureCSRF(s.d, r, f.Type, s.d.Config().DisableAPIFlowEnforcement(ctx), s.d.GenerateCSRFToken, p.CSRFToken); err != nil {
|
||||
return nil, s.handleLoginError(r, f, err)
|
||||
}
|
||||
|
||||
i, c, err := s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(ctx, s.ID(), sess.IdentityID.String())
|
||||
if errors.Is(err, sqlcon.ErrNoRows()) {
|
||||
return nil, s.handleLoginError(r, f, errors.WithStack(schema.NewNoLookupDefined()))
|
||||
} else if err != nil {
|
||||
return nil, s.handleLoginError(r, f, err)
|
||||
}
|
||||
|
||||
var o identity.CredentialsLookupConfig
|
||||
if err := json.Unmarshal(c.Config, &o); err != nil {
|
||||
return nil, x.WrapWithIdentityIDError(errors.WithStack(herodot.ErrInternalServerError().WithReason("The lookup secrets could not be decoded properly").WithDebug(err.Error()).WithWrap(err)), i.ID)
|
||||
}
|
||||
|
||||
var found bool
|
||||
for k, rc := range o.RecoveryCodes {
|
||||
if rc.Code == p.Code {
|
||||
if time.Time(rc.UsedAt).IsZero() {
|
||||
o.RecoveryCodes[k].UsedAt = sqlxx.NullTime(time.Now().UTC().Round(time.Second))
|
||||
found = true
|
||||
} else {
|
||||
return nil, s.handleLoginError(r, f, x.WrapWithIdentityIDError(errors.WithStack(schema.NewLookupAlreadyUsed()), i.ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return nil, s.handleLoginError(r, f, x.WrapWithIdentityIDError(errors.WithStack(schema.NewErrorValidationLookupInvalid()), i.ID))
|
||||
}
|
||||
|
||||
// We can't use a transaction here because HydrateIdentityAssociations (used by update) does not support transactions.
|
||||
toUpdate, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(ctx, sess.IdentityID)
|
||||
if err != nil {
|
||||
return nil, s.handleLoginError(r, f, x.WrapWithIdentityIDError(err, i.ID))
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(&o)
|
||||
if err != nil {
|
||||
return nil, s.handleLoginError(r, f, x.WrapWithIdentityIDError(errors.WithStack(herodot.ErrInternalServerError().WithReason("Unable to encode updated lookup secrets.").WithDebug(err.Error())), i.ID))
|
||||
}
|
||||
|
||||
c.Config = encoded
|
||||
toUpdate.SetCredentials(s.ID(), *c)
|
||||
|
||||
// We can't use a transaction here because HydrateIdentityAssociations (used by update) does not support transactions.
|
||||
if err := s.d.IdentityManager().Update(ctx, toUpdate,
|
||||
// We need to allow write protected traits because we are updating the lookup secrets.
|
||||
identity.ManagerAllowWriteProtectedTraits,
|
||||
); err != nil {
|
||||
return nil, s.handleLoginError(r, f, x.WrapWithIdentityIDError(errors.WithStack(herodot.ErrInternalServerError().WithReason("Unable to update identity.").WithDebug(err.Error())), i.ID))
|
||||
}
|
||||
|
||||
f.Active = s.ID()
|
||||
if err = s.d.LoginFlowPersister().UpdateLoginFlow(ctx, f); err != nil {
|
||||
return nil, s.handleLoginError(r, f, x.WrapWithIdentityIDError(errors.WithStack(herodot.ErrInternalServerError().WithReason("Could not update flow.").WithDebug(err.Error())), i.ID))
|
||||
}
|
||||
|
||||
return i, nil
|
||||
}
|
||||
444
selfservice/strategy/lookup/login_test.go
Normal file
444
selfservice/strategy/lookup/login_test.go
Normal file
@@ -0,0 +1,444 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package lookup_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/selfservice/flow/login"
|
||||
"github.com/ory/kratos/text"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
"github.com/ory/x/assertx"
|
||||
"github.com/ory/x/configx"
|
||||
"github.com/ory/x/contextx"
|
||||
"github.com/ory/x/snapshotx"
|
||||
)
|
||||
|
||||
var lookupCodeGJSONQuery = "ui.nodes.#(attributes.name==" + identity.CredentialsTypeLookup.String() + ")"
|
||||
|
||||
func TestCompleteLogin(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t,
|
||||
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypePassword, false)),
|
||||
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypeLookup, true)),
|
||||
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/login.schema.json")),
|
||||
)
|
||||
|
||||
publicTS, _ := testhelpers.NewKratosServer(t, reg)
|
||||
|
||||
errTS := testhelpers.NewErrorTestServer(t, reg)
|
||||
uiTS := testhelpers.NewLoginUIFlowEchoServer(t, reg)
|
||||
redirTS := testhelpers.NewRedirSessionEchoTS(t, reg)
|
||||
|
||||
t.Run("case=lookup payload is set when identity has lookup", func(t *testing.T) {
|
||||
id, _ := createIdentity(t, reg)
|
||||
|
||||
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id)
|
||||
f := testhelpers.InitializeLoginFlowViaAPICtx(t.Context(), t, apiClient, publicTS, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2))
|
||||
testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{"0.attributes.value"})
|
||||
})
|
||||
|
||||
t.Run("case=lookup payload is not set when identity has no lookup", func(t *testing.T) {
|
||||
id := createIdentityWithoutLookup(t, reg)
|
||||
|
||||
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id)
|
||||
f := testhelpers.InitializeLoginFlowViaAPICtx(t.Context(), t, apiClient, publicTS, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2))
|
||||
assertx.EqualAsJSON(t, nil, f.Ui.Nodes)
|
||||
})
|
||||
|
||||
t.Run("case=lookup payload is not set when identity has no lookup", func(t *testing.T) {
|
||||
id := createIdentityWithoutLookup(t, reg)
|
||||
|
||||
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id)
|
||||
f := testhelpers.InitializeLoginFlowViaAPICtx(t.Context(), t, apiClient, publicTS, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2))
|
||||
assertx.EqualAsJSON(t, nil, f.Ui.Nodes)
|
||||
})
|
||||
|
||||
doAPIFlowWithClient := func(t *testing.T, v func(url.Values), id *identity.Identity, apiClient *http.Client, forced bool) (string, *http.Response) {
|
||||
f := testhelpers.InitializeLoginFlowViaAPICtx(t.Context(), t, apiClient, publicTS, forced, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2))
|
||||
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
||||
values.Set("method", identity.CredentialsTypeLookup.String())
|
||||
v(values)
|
||||
payload := testhelpers.EncodeFormAsJSON(t, true, values)
|
||||
return testhelpers.LoginMakeRequest(t, true, false, f, apiClient, payload)
|
||||
}
|
||||
|
||||
doAPIFlow := func(t *testing.T, v func(url.Values), id *identity.Identity) (string, *http.Response) {
|
||||
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id)
|
||||
return doAPIFlowWithClient(t, v, id, apiClient, false)
|
||||
}
|
||||
|
||||
doBrowserFlowWithClient := func(t *testing.T, spa bool, v func(url.Values), id *identity.Identity, browserClient *http.Client, forced bool) (string, *http.Response) {
|
||||
f := testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, publicTS, forced, spa, false, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2))
|
||||
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
||||
values.Set("method", identity.CredentialsTypeLookup.String())
|
||||
v(values)
|
||||
return testhelpers.LoginMakeRequest(t, false, spa, f, browserClient, values.Encode())
|
||||
}
|
||||
|
||||
doBrowserFlow := func(t *testing.T, spa bool, v func(url.Values), id *identity.Identity) (string, *http.Response) {
|
||||
browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(ctx, t, reg, id)
|
||||
return doBrowserFlowWithClient(t, spa, v, id, browserClient, false)
|
||||
}
|
||||
|
||||
checkURL := func(t *testing.T, shouldRedirect bool, res *http.Response) {
|
||||
if shouldRedirect {
|
||||
assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/login-ts")
|
||||
} else {
|
||||
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("case=should fail if code is invalid", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
d string
|
||||
code string
|
||||
message string
|
||||
key string
|
||||
}{
|
||||
{d: "empty", key: lookupCodeGJSONQuery + ".messages.0.text", message: "Property lookup_secret is missing."},
|
||||
{d: "invalid", key: "ui.messages.0.text", code: "invalid", message: text.NewErrorValidationLookupInvalid().Text},
|
||||
{d: "already-used", key: "ui.messages.0.text", code: "key-1", message: text.NewErrorValidationLookupAlreadyUsed().Text},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("code=%s", tc.d), func(t *testing.T) {
|
||||
id, _ := createIdentity(t, reg)
|
||||
payload := func(v url.Values) {
|
||||
v.Set(node.LookupCodeEnter, tc.code)
|
||||
}
|
||||
|
||||
check := func(t *testing.T, shouldRedirect bool, body string, res *http.Response) {
|
||||
checkURL(t, shouldRedirect, res)
|
||||
assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
|
||||
assert.Equal(t, tc.message, gjson.Get(body, tc.key).String(), "%s", body)
|
||||
}
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
body, res := doAPIFlow(t, payload, id)
|
||||
check(t, false, body, res)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
body, res := doBrowserFlow(t, false, payload, id)
|
||||
check(t, true, body, res)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
body, res := doBrowserFlow(t, true, payload, id)
|
||||
check(t, false, body, res)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case=should fail if lookup was not set up for identity", func(t *testing.T) {
|
||||
id := createIdentityWithoutLookup(t, reg)
|
||||
|
||||
payload := func(v url.Values) {
|
||||
v.Set(node.LookupCodeEnter, "1111111")
|
||||
}
|
||||
|
||||
check := func(t *testing.T, shouldRedirect bool, body string, res *http.Response) {
|
||||
checkURL(t, shouldRedirect, res)
|
||||
assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
|
||||
assert.Equal(t, text.NewErrorValidationNoLookup().Text, gjson.Get(body, "ui.messages.0.text").String(), "%s", body)
|
||||
}
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
body, res := doAPIFlow(t, payload, id)
|
||||
check(t, false, body, res)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
body, res := doBrowserFlow(t, false, payload, id)
|
||||
check(t, true, body, res)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
body, res := doBrowserFlow(t, true, payload, id)
|
||||
check(t, false, body, res)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=should pass when code is supplied correctly", func(t *testing.T) {
|
||||
id, _ := createIdentity(t, reg)
|
||||
payload := func(code string) func(v url.Values) {
|
||||
return func(v url.Values) {
|
||||
v.Set(node.LookupCodeEnter, code)
|
||||
}
|
||||
}
|
||||
|
||||
startAt := time.Now()
|
||||
check := func(t *testing.T, shouldRedirect bool, body string, res *http.Response, usedKey string, expectedAuths int) {
|
||||
prefix := "session."
|
||||
if shouldRedirect {
|
||||
assert.Contains(t, res.Request.URL.String(), redirTS.URL+"/return-ts")
|
||||
prefix = ""
|
||||
} else {
|
||||
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
|
||||
}
|
||||
assert.True(t, gjson.Get(body, prefix+"active").Bool(), "%s", body)
|
||||
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel2, gjson.Get(body, prefix+"authenticator_assurance_level").String())
|
||||
require.Len(t, gjson.Get(body, prefix+"authentication_methods").Array(), expectedAuths)
|
||||
assert.EqualValues(t, identity.CredentialsTypePassword, gjson.Get(body, prefix+"authentication_methods.0.method").String())
|
||||
assert.True(t, gjson.Get(body, prefix+"authentication_methods.0.completed_at").Time().After(startAt))
|
||||
assert.EqualValues(t, identity.CredentialsTypeLookup, gjson.Get(body, prefix+"authentication_methods.1.method").String())
|
||||
assert.True(t, gjson.Get(body, prefix+"authentication_methods.1.completed_at").Time().After(startAt))
|
||||
assert.True(t, gjson.Get(body, prefix+"authentication_methods.1.completed_at").Time().After(gjson.Get(body, prefix+"authentication_methods.0.completed_at").Time()))
|
||||
if expectedAuths == 3 {
|
||||
assert.EqualValues(t, identity.CredentialsTypeLookup, gjson.Get(body, prefix+"authentication_methods.2.method").String())
|
||||
assert.True(t, gjson.Get(body, prefix+"authentication_methods.2.completed_at").Time().After(startAt))
|
||||
assert.True(t, gjson.Get(body, prefix+"authentication_methods.2.completed_at").Time().After(gjson.Get(body, prefix+"authentication_methods.1.completed_at").Time()))
|
||||
assert.False(t, gjson.Get(body, prefix+"authenticated_at").Time().Before(gjson.Get(body, prefix+"authentication_methods.2.completed_at").Time()))
|
||||
} else {
|
||||
assert.False(t, gjson.Get(body, prefix+"authenticated_at").Time().Before(gjson.Get(body, prefix+"authentication_methods.1.completed_at").Time()))
|
||||
}
|
||||
|
||||
actual, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(context.Background(), uuid.FromStringOrNil(gjson.Get(body, prefix+"identity.id").String()))
|
||||
require.NoError(t, err)
|
||||
creds, ok := actual.GetCredentials(identity.CredentialsTypeLookup)
|
||||
require.True(t, ok)
|
||||
|
||||
var conf identity.CredentialsLookupConfig
|
||||
require.NoError(t, json.Unmarshal(creds.Config, &conf))
|
||||
|
||||
var found bool
|
||||
for _, rc := range conf.RecoveryCodes {
|
||||
if rc.Code == usedKey {
|
||||
found = true
|
||||
require.False(t, time.Time(rc.UsedAt).IsZero())
|
||||
}
|
||||
}
|
||||
|
||||
require.True(t, found)
|
||||
}
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id)
|
||||
body, res := doAPIFlowWithClient(t, payload("key-0"), id, apiClient, false)
|
||||
check(t, false, body, res, "key-0", 2)
|
||||
// We can still use another key
|
||||
body, res = doAPIFlowWithClient(t, payload("key-2"), id, apiClient, true)
|
||||
check(t, false, body, res, "key-2", 3)
|
||||
assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(ctx, t, reg, id)
|
||||
body, res := doBrowserFlowWithClient(t, false, payload("key-3"), id, browserClient, false)
|
||||
check(t, true, body, res, "key-3", 2)
|
||||
// We can still use another key
|
||||
body, res = doBrowserFlowWithClient(t, false, payload("key-5"), id, browserClient, true)
|
||||
check(t, true, body, res, "key-5", 3)
|
||||
assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(ctx, t, reg, id)
|
||||
body, res := doBrowserFlowWithClient(t, true, payload("key-6"), id, browserClient, false)
|
||||
check(t, false, body, res, "key-6", 2)
|
||||
// We can still use another key
|
||||
body, res = doBrowserFlowWithClient(t, true, payload("key-8"), id, browserClient, true)
|
||||
check(t, false, body, res, "key-8", 3)
|
||||
|
||||
assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(body, "continue_with.0.action").String(), "%s", body)
|
||||
assert.Contains(t, gjson.Get(body, "continue_with.0.redirect_browser_to").String(), conf.SelfServiceBrowserDefaultReturnTo(ctx).String(), "%s", body)
|
||||
})
|
||||
})
|
||||
|
||||
// Regression coverage for https://github.com/ory/kratos/issues/4561.
|
||||
// Lookup-secret has the same shape of bug as TOTP: the AAL2 login
|
||||
// flow looks the credential up via FindByCredentialsIdentifier keyed
|
||||
// by the identity ID, so the import path must persist a matching
|
||||
// row in identity_credential_identifiers. Both the create and
|
||||
// update entry points are covered.
|
||||
t.Run("case=imported lookup-secret via create identity should succeed at AAL2 login", func(t *testing.T) {
|
||||
id, codes := createIdentityWithImportedLookupViaCreate(t.Context(), t, reg)
|
||||
|
||||
actual, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), id.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{actual.ID.String()}, actual.Credentials[identity.CredentialsTypeLookup].Identifiers)
|
||||
|
||||
payload := func(v url.Values) {
|
||||
v.Set(node.LookupCodeEnter, codes[0].Code)
|
||||
}
|
||||
|
||||
startAt := time.Now()
|
||||
check := func(t *testing.T, body string) {
|
||||
assert.True(t, gjson.Get(body, "session.active").Bool(), "%s", body)
|
||||
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel2, gjson.Get(body, "session.authenticator_assurance_level").String())
|
||||
require.Len(t, gjson.Get(body, "session.authentication_methods").Array(), 2, "%s", body)
|
||||
assert.EqualValues(t, identity.CredentialsTypePassword, gjson.Get(body, "session.authentication_methods.0.method").String(), "%s", body)
|
||||
assert.EqualValues(t, identity.CredentialsTypeLookup, gjson.Get(body, "session.authentication_methods.1.method").String(), "%s", body)
|
||||
assert.True(t, gjson.Get(body, "session.authentication_methods.1.completed_at").Time().After(startAt), "%s", body)
|
||||
}
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id)
|
||||
body, res := doAPIFlowWithClient(t, payload, id, apiClient, false)
|
||||
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
|
||||
check(t, body)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=imported lookup-secret via update identity should succeed at AAL2 login", func(t *testing.T) {
|
||||
id, codes := createIdentityWithImportedLookupViaUpdate(t.Context(), t, reg)
|
||||
|
||||
actual, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), id.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{actual.ID.String()}, actual.Credentials[identity.CredentialsTypeLookup].Identifiers)
|
||||
|
||||
payload := func(v url.Values) {
|
||||
v.Set(node.LookupCodeEnter, codes[0].Code)
|
||||
}
|
||||
|
||||
startAt := time.Now()
|
||||
check := func(t *testing.T, body string) {
|
||||
assert.True(t, gjson.Get(body, "session.active").Bool(), "%s", body)
|
||||
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel2, gjson.Get(body, "session.authenticator_assurance_level").String())
|
||||
require.Len(t, gjson.Get(body, "session.authentication_methods").Array(), 2, "%s", body)
|
||||
assert.EqualValues(t, identity.CredentialsTypePassword, gjson.Get(body, "session.authentication_methods.0.method").String(), "%s", body)
|
||||
assert.EqualValues(t, identity.CredentialsTypeLookup, gjson.Get(body, "session.authentication_methods.1.method").String(), "%s", body)
|
||||
assert.True(t, gjson.Get(body, "session.authentication_methods.1.completed_at").Time().After(startAt), "%s", body)
|
||||
}
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id)
|
||||
body, res := doAPIFlowWithClient(t, payload, id, apiClient, false)
|
||||
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
|
||||
check(t, body)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=should fail because lookup can not handle AAL1", func(t *testing.T) {
|
||||
apiClient := testhelpers.NewDebugClient(t)
|
||||
f := testhelpers.InitializeLoginFlowViaAPICtx(ctx, t, apiClient, publicTS, false)
|
||||
|
||||
update, err := reg.LoginFlowPersister().GetLoginFlow(context.Background(), uuid.FromStringOrNil(f.Id))
|
||||
require.NoError(t, err)
|
||||
update.RequestedAAL = identity.AuthenticatorAssuranceLevel1
|
||||
require.NoError(t, reg.LoginFlowPersister().UpdateLoginFlow(context.Background(), update))
|
||||
|
||||
req, err := http.NewRequest("POST", f.Ui.Action, bytes.NewBufferString(`{"method":"lookup"}`))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
res, err := testhelpers.NewTestClient(t).Do(req)
|
||||
require.NoError(t, err)
|
||||
body := x.MustReadAll(res.Body)
|
||||
require.NoError(t, res.Body.Close())
|
||||
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
|
||||
assert.Equal(t, text.NewErrorValidationLoginNoStrategyFound().Text, gjson.GetBytes(body, "ui.messages.0.text").String())
|
||||
})
|
||||
|
||||
t.Run("case=should pass without csrf if API flow", func(t *testing.T) {
|
||||
id, _ := createIdentity(t, reg)
|
||||
body, res := doAPIFlow(t, func(v url.Values) {
|
||||
v.Del("csrf_token")
|
||||
v.Set(node.LookupCodeEnter, "111111")
|
||||
}, id)
|
||||
|
||||
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
|
||||
assert.Equal(t, text.NewErrorValidationLookupInvalid().Text, gjson.Get(body, "ui.messages.0.text").String(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("case=should fail if CSRF token is invalid", func(t *testing.T) {
|
||||
id, _ := createIdentity(t, reg)
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
body, res := doBrowserFlow(t, false, func(v url.Values) {
|
||||
v.Del("csrf_token")
|
||||
v.Set(node.LookupCodeEnter, "111111")
|
||||
}, id)
|
||||
|
||||
assert.Contains(t, res.Request.URL.String(), errTS.URL)
|
||||
assert.Equal(t, nosurfx.ErrInvalidCSRFToken().Reason(), gjson.Get(body, "reason").String(), body)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
body, res := doBrowserFlow(t, true, func(v url.Values) {
|
||||
v.Del("csrf_token")
|
||||
v.Set(node.LookupCodeEnter, "111111")
|
||||
}, id)
|
||||
|
||||
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
|
||||
assert.Equal(t, nosurfx.ErrInvalidCSRFToken().Reason(), gjson.Get(body, "error.reason").String(), body)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormHydration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
|
||||
ctx = contextx.WithConfigValue(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeLookup)+".enabled", true)
|
||||
|
||||
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/login.schema.json")
|
||||
|
||||
s, err := reg.AllLoginStrategies().Strategy(identity.CredentialsTypeLookup)
|
||||
require.NoError(t, err)
|
||||
fh, ok := s.(login.AAL2FormHydrator)
|
||||
require.True(t, ok)
|
||||
|
||||
toSnapshot := func(t *testing.T, f *login.Flow) {
|
||||
t.Helper()
|
||||
// The CSRF token has a unique value that messes with the snapshot - ignore it.
|
||||
f.UI.Nodes.ResetNodes("csrf_token")
|
||||
snapshotx.SnapshotT(t, f.UI.Nodes)
|
||||
}
|
||||
|
||||
newFlow := func(ctx context.Context, t *testing.T) (*http.Request, *login.Flow) {
|
||||
r := httptest.NewRequest("GET", "/self-service/login/browser", nil)
|
||||
r = r.WithContext(ctx)
|
||||
t.Helper()
|
||||
f, err := login.NewFlow(reg, r, flow.TypeBrowser)
|
||||
f.UI.Nodes = make(node.Nodes, 0)
|
||||
require.NoError(t, err)
|
||||
return r, f
|
||||
}
|
||||
|
||||
t.Run("method=PopulateLoginMethodSecondFactor", func(t *testing.T) {
|
||||
id, _ := createIdentity(t, reg)
|
||||
headers := testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id).Transport.(*testhelpers.TransportWithHeader).GetHeader()
|
||||
r, f := newFlow(ctx, t)
|
||||
|
||||
r.Header = headers
|
||||
f.RequestedAAL = identity.AuthenticatorAssuranceLevel2
|
||||
|
||||
require.NoError(t, fh.PopulateLoginMethodSecondFactor(r, f))
|
||||
toSnapshot(t, f)
|
||||
})
|
||||
|
||||
t.Run("method=PopulateLoginMethodSecondFactorRefresh", func(t *testing.T) {
|
||||
id, _ := createIdentity(t, reg)
|
||||
headers := testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id).Transport.(*testhelpers.TransportWithHeader).GetHeader()
|
||||
r, f := newFlow(ctx, t)
|
||||
|
||||
r.Header = headers
|
||||
f.RequestedAAL = identity.AuthenticatorAssuranceLevel2
|
||||
|
||||
require.NoError(t, fh.PopulateLoginMethodSecondFactorRefresh(r, f))
|
||||
toSnapshot(t, f)
|
||||
})
|
||||
}
|
||||
30
selfservice/strategy/lookup/nodes.go
Normal file
30
selfservice/strategy/lookup/nodes.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package lookup
|
||||
|
||||
import (
|
||||
"github.com/ory/kratos/text"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
)
|
||||
|
||||
func NewRevealLookupNode() *node.Node {
|
||||
return node.NewInputField(node.LookupReveal, "true", node.LookupGroup, node.InputAttributeTypeSubmit).
|
||||
WithMetaLabel(text.NewInfoSelfServiceSettingsRevealLookup())
|
||||
}
|
||||
|
||||
func NewRegenerateLookupNode() *node.Node {
|
||||
return node.NewInputField(
|
||||
node.LookupRegenerate, "true", node.LookupGroup, node.InputAttributeTypeSubmit).
|
||||
WithMetaLabel(text.NewInfoSelfServiceSettingsRegenerateLookup())
|
||||
}
|
||||
|
||||
func NewDisableLookupNode() *node.Node {
|
||||
return node.NewInputField(node.LookupDisable, "true", node.LookupGroup, node.InputAttributeTypeSubmit).
|
||||
WithMetaLabel(text.NewInfoSelfServiceSettingsDisableLookup())
|
||||
}
|
||||
|
||||
func NewConfirmLookupNode() *node.Node {
|
||||
return node.NewInputField(node.LookupConfirm, "true", node.LookupGroup, node.InputAttributeTypeSubmit).
|
||||
WithMetaLabel(text.NewInfoSelfServiceSettingsLookupConfirm())
|
||||
}
|
||||
14
selfservice/strategy/lookup/schema.go
Normal file
14
selfservice/strategy/lookup/schema.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package lookup
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed .schema/login.schema.json
|
||||
var loginSchema []byte
|
||||
|
||||
//go:embed .schema/settings.schema.json
|
||||
var settingsSchema []byte
|
||||
382
selfservice/strategy/lookup/settings.go
Normal file
382
selfservice/strategy/lookup/settings.go
Normal file
@@ -0,0 +1,382 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package lookup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
|
||||
"github.com/ory/x/otelx"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
|
||||
"github.com/ory/x/randx"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
|
||||
"github.com/ory/kratos/session"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/kratos/continuity"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/selfservice/flow/settings"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/x/decoderx"
|
||||
)
|
||||
|
||||
func (s *Strategy) SettingsStrategyID() string {
|
||||
return identity.CredentialsTypeLookup.String()
|
||||
}
|
||||
|
||||
const (
|
||||
internalContextKeyRevealed = "revealed"
|
||||
InternalContextKeyRegenerated = "regenerated"
|
||||
)
|
||||
|
||||
const numCodes = 12
|
||||
|
||||
var allSettingsNodes = []string{
|
||||
node.LookupRegenerate,
|
||||
node.LookupReveal,
|
||||
node.LookupRegenerate,
|
||||
node.LookupDisable,
|
||||
node.LookupCodes,
|
||||
node.LookupConfirm,
|
||||
}
|
||||
|
||||
// Update Settings Flow with Lookup Method
|
||||
//
|
||||
// swagger:model updateSettingsFlowWithLookupMethod
|
||||
type updateSettingsFlowWithLookupMethod struct {
|
||||
// If set to true will reveal the lookup secrets
|
||||
RevealLookup bool `json:"lookup_secret_reveal"`
|
||||
|
||||
// If set to true will regenerate the lookup secrets
|
||||
RegenerateLookup bool `json:"lookup_secret_regenerate"`
|
||||
|
||||
// If set to true will save the regenerated lookup secrets
|
||||
ConfirmLookup bool `json:"lookup_secret_confirm"`
|
||||
|
||||
// Disables this method if true.
|
||||
DisableLookup bool `json:"lookup_secret_disable"`
|
||||
|
||||
// CSRFToken is the anti-CSRF token
|
||||
CSRFToken string `json:"csrf_token"`
|
||||
|
||||
// Method
|
||||
//
|
||||
// Should be set to "lookup" when trying to add, update, or remove a lookup pairing.
|
||||
//
|
||||
// required: true
|
||||
Method string `json:"method"`
|
||||
|
||||
// Flow is flow ID.
|
||||
//
|
||||
// swagger:ignore
|
||||
Flow string `json:"flow"`
|
||||
|
||||
// Transient data to pass along to any webhooks
|
||||
//
|
||||
// required: false
|
||||
TransientPayload json.RawMessage `json:"transient_payload,omitempty" form:"transient_payload"`
|
||||
}
|
||||
|
||||
func (p *updateSettingsFlowWithLookupMethod) GetFlowID() uuid.UUID {
|
||||
return x.ParseUUID(p.Flow)
|
||||
}
|
||||
|
||||
func (p *updateSettingsFlowWithLookupMethod) SetFlowID(rid uuid.UUID) {
|
||||
p.Flow = rid.String()
|
||||
}
|
||||
|
||||
func (s *Strategy) Settings(ctx context.Context, w http.ResponseWriter, r *http.Request, f *settings.Flow, ss *session.Session) (_ *settings.UpdateContext, err error) {
|
||||
ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.lookup.Strategy.Settings")
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
var p updateSettingsFlowWithLookupMethod
|
||||
ctxUpdate, err := settings.PrepareUpdate(s.d, w, r, f, ss, settings.ContinuityKey(s.SettingsStrategyID()), &p)
|
||||
if errors.Is(err, settings.ErrContinuePreviousAction) {
|
||||
return ctxUpdate, s.continueSettingsFlow(ctx, r, ctxUpdate, p)
|
||||
} else if err != nil {
|
||||
return ctxUpdate, s.handleSettingsError(w, r, ctxUpdate, p, err)
|
||||
}
|
||||
|
||||
if err := s.decodeSettingsFlow(r, &p); err != nil {
|
||||
return ctxUpdate, s.handleSettingsError(w, r, ctxUpdate, p, err)
|
||||
}
|
||||
|
||||
if p.RegenerateLookup || p.RevealLookup || p.ConfirmLookup || p.DisableLookup {
|
||||
// This method has only two submit buttons
|
||||
p.Method = s.SettingsStrategyID()
|
||||
if err := flow.MethodEnabledAndAllowed(ctx, f.GetFlowName(), s.SettingsStrategyID(), p.Method, s.d); err != nil {
|
||||
return nil, s.handleSettingsError(w, r, ctxUpdate, p, err)
|
||||
}
|
||||
} else {
|
||||
span.SetAttributes(attribute.String("not_responsible_reason", "neither reveal, regenerate, confirm, nor disable was set"))
|
||||
return nil, errors.WithStack(flow.ErrStrategyNotResponsible)
|
||||
}
|
||||
|
||||
// This does not come from the payload!
|
||||
p.Flow = ctxUpdate.Flow.ID.String()
|
||||
if err := s.continueSettingsFlow(ctx, r, ctxUpdate, p); err != nil {
|
||||
return ctxUpdate, s.handleSettingsError(w, r, ctxUpdate, p, err)
|
||||
}
|
||||
|
||||
return ctxUpdate, nil
|
||||
}
|
||||
|
||||
func (s *Strategy) decodeSettingsFlow(r *http.Request, dest interface{}) error {
|
||||
compiler, err := decoderx.HTTPRawJSONSchemaCompiler(settingsSchema)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return decoderx.Decode(r, dest, compiler,
|
||||
decoderx.HTTPDecoderSetValidatePayloads(true),
|
||||
decoderx.HTTPDecoderJSONFollowsFormFormat(),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Strategy) continueSettingsFlow(ctx context.Context, r *http.Request, ctxUpdate *settings.UpdateContext, p updateSettingsFlowWithLookupMethod) error {
|
||||
if p.ConfirmLookup || p.RevealLookup || p.RegenerateLookup || p.DisableLookup {
|
||||
if err := flow.MethodEnabledAndAllowed(ctx, flow.SettingsFlow, s.SettingsStrategyID(), s.SettingsStrategyID(), s.d); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := flow.EnsureCSRF(s.d, r, ctxUpdate.Flow.Type, s.d.Config().DisableAPIFlowEnforcement(ctx), s.d.GenerateCSRFToken, p.CSRFToken); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !s.d.SessionManager().IsPrivileged(ctx, ctxUpdate.Session) {
|
||||
return errors.WithStack(settings.NewFlowNeedsReAuth())
|
||||
}
|
||||
} else {
|
||||
return errors.New("ended up in unexpected state")
|
||||
}
|
||||
|
||||
if p.ConfirmLookup {
|
||||
return s.continueSettingsFlowConfirm(ctx, ctxUpdate)
|
||||
} else if p.RevealLookup {
|
||||
if err := s.continueSettingsFlowReveal(ctx, ctxUpdate); err != nil {
|
||||
return err
|
||||
}
|
||||
return flow.ErrStrategyAsksToReturnToUI
|
||||
} else if p.DisableLookup {
|
||||
return s.continueSettingsFlowDisable(ctx, ctxUpdate)
|
||||
} else if p.RegenerateLookup {
|
||||
if err := s.continueSettingsFlowRegenerate(ctx, ctxUpdate); err != nil {
|
||||
return err
|
||||
}
|
||||
// regen
|
||||
return flow.ErrStrategyAsksToReturnToUI
|
||||
}
|
||||
|
||||
return errors.New("ended up in unexpected state")
|
||||
}
|
||||
|
||||
func (s *Strategy) continueSettingsFlowDisable(ctx context.Context, ctxUpdate *settings.UpdateContext) error {
|
||||
i, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(ctx, ctxUpdate.Session.Identity.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.DeleteCredentialsType(s.ID())
|
||||
|
||||
for _, n := range allSettingsNodes {
|
||||
ctxUpdate.Flow.UI.Nodes.Remove(n)
|
||||
}
|
||||
|
||||
ctxUpdate.Flow.UI.Nodes.Upsert(NewRegenerateLookupNode())
|
||||
ctxUpdate.Flow.InternalContext, err = sjson.SetBytes(ctxUpdate.Flow.InternalContext, flow.PrefixInternalContextKey(s.ID(), internalContextKeyRevealed), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctxUpdate.Flow.InternalContext, err = sjson.SetRawBytes(ctxUpdate.Flow.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeyRegenerated), []byte("{}"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.d.SettingsFlowPersister().UpdateSettingsFlow(ctx, ctxUpdate.Flow); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctxUpdate.UpdateIdentity(i)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Strategy) continueSettingsFlowReveal(ctx context.Context, ctxUpdate *settings.UpdateContext) error {
|
||||
hasLookup, err := s.identityHasLookup(ctx, ctxUpdate.Session.Identity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !hasLookup {
|
||||
return errors.WithStack(herodot.ErrBadRequest().WithReasonf("Can not reveal lookup codes because you have none."))
|
||||
}
|
||||
|
||||
_, cred, err := s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(ctx, s.ID(), ctxUpdate.Session.IdentityID.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var creds identity.CredentialsLookupConfig
|
||||
if err := json.Unmarshal(cred.Config, &creds); err != nil {
|
||||
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to decode lookup codes from JSON.").WithDebug(err.Error()))
|
||||
}
|
||||
|
||||
for _, n := range allSettingsNodes {
|
||||
ctxUpdate.Flow.UI.Nodes.Remove(n)
|
||||
}
|
||||
|
||||
ctxUpdate.Flow.UI.Nodes.Upsert(creds.ToNode())
|
||||
ctxUpdate.Flow.UI.Nodes.Upsert(NewDisableLookupNode())
|
||||
ctxUpdate.Flow.UI.Nodes.Upsert(NewRegenerateLookupNode())
|
||||
|
||||
ctxUpdate.Flow.InternalContext, err = sjson.SetBytes(ctxUpdate.Flow.InternalContext, flow.PrefixInternalContextKey(s.ID(), internalContextKeyRevealed), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.d.SettingsFlowPersister().UpdateSettingsFlow(ctx, ctxUpdate.Flow); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Strategy) continueSettingsFlowRegenerate(ctx context.Context, ctxUpdate *settings.UpdateContext) error {
|
||||
codes := make([]identity.RecoveryCode, numCodes)
|
||||
for k := range codes {
|
||||
codes[k] = identity.RecoveryCode{Code: randx.MustString(8, randx.AlphaLowerNum)}
|
||||
}
|
||||
|
||||
for _, n := range allSettingsNodes {
|
||||
ctxUpdate.Flow.UI.Nodes.Remove(n)
|
||||
}
|
||||
|
||||
ctxUpdate.Flow.UI.Nodes.Upsert((&identity.CredentialsLookupConfig{RecoveryCodes: codes}).ToNode())
|
||||
ctxUpdate.Flow.UI.Nodes.Upsert(NewConfirmLookupNode())
|
||||
|
||||
var err error
|
||||
ctxUpdate.Flow.InternalContext, err = sjson.SetBytes(ctxUpdate.Flow.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeyRegenerated), codes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.d.SettingsFlowPersister().UpdateSettingsFlow(ctx, ctxUpdate.Flow); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Strategy) continueSettingsFlowConfirm(ctx context.Context, ctxUpdate *settings.UpdateContext) error {
|
||||
codes := gjson.GetBytes(ctxUpdate.Flow.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeyRegenerated)).Array()
|
||||
if len(codes) != numCodes {
|
||||
return errors.WithStack(herodot.ErrBadRequest().WithReasonf("You must (re-)generate recovery backup codes before you can save them."))
|
||||
}
|
||||
|
||||
rc := make([]identity.RecoveryCode, len(codes))
|
||||
for k := range rc {
|
||||
rc[k] = identity.RecoveryCode{Code: codes[k].Get("code").String()}
|
||||
}
|
||||
|
||||
co, err := json.Marshal(&identity.CredentialsLookupConfig{RecoveryCodes: rc})
|
||||
if err != nil {
|
||||
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to encode totp options to JSON: %s", err))
|
||||
}
|
||||
|
||||
i, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(ctx, ctxUpdate.Session.Identity.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// We do not really need the identifier, so we add the identity's ID
|
||||
c := &identity.Credentials{Type: s.ID(), Identifiers: []string{i.ID.String()}, Config: co}
|
||||
c.Config = co
|
||||
i.SetCredentials(s.ID(), *c)
|
||||
|
||||
// Remove the TOTP URL from the internal context now that it is set!
|
||||
ctxUpdate.Flow.InternalContext, err = sjson.DeleteBytes(ctxUpdate.Flow.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeyRegenerated))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.d.SettingsFlowPersister().UpdateSettingsFlow(ctx, ctxUpdate.Flow); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Since we added the method, it also means that we have authenticated it
|
||||
if err := s.d.SessionManager().SessionAddAuthenticationMethods(ctx, ctxUpdate.Session.ID, session.AuthenticationMethod{
|
||||
Method: s.ID(),
|
||||
AAL: identity.AuthenticatorAssuranceLevel2,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctxUpdate.UpdateIdentity(i)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Strategy) identityHasLookup(ctx context.Context, id *identity.Identity) (bool, error) {
|
||||
if len(id.Credentials) == 0 {
|
||||
if err := s.d.PrivilegedIdentityPool().HydrateIdentityAssociations(ctx, id, identity.ExpandCredentials); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
count, err := s.CountActiveMultiFactorCredentials(ctx, id.Credentials)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *Strategy) PopulateSettingsMethod(ctx context.Context, r *http.Request, id *identity.Identity, f *settings.Flow) (err error) {
|
||||
ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.lookup.Strategy.PopulateSettingsMethod")
|
||||
defer otelx.End(span, &err)
|
||||
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
||||
|
||||
hasLookup, err := s.identityHasLookup(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if hasLookup {
|
||||
f.UI.Nodes.Upsert(NewRevealLookupNode())
|
||||
f.UI.Nodes.Upsert(NewDisableLookupNode())
|
||||
} else {
|
||||
f.UI.Nodes.Upsert(NewRegenerateLookupNode())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Strategy) handleSettingsError(w http.ResponseWriter, r *http.Request, ctxUpdate *settings.UpdateContext, p updateSettingsFlowWithLookupMethod, err error) error {
|
||||
// Do not pause flow if the flow type is an API flow as we can't save cookies in those flows.
|
||||
if e := new(settings.FlowNeedsReAuth); errors.As(err, &e) && ctxUpdate.Flow != nil && ctxUpdate.Flow.Type == flow.TypeBrowser {
|
||||
key := settings.ContinuityKey(s.SettingsStrategyID())
|
||||
cookieStore := continuity.NewCookieReferenceStore(s.d.ContinuityCookieManager(r.Context()))
|
||||
opts := settings.ContinuityOptions(p, ctxUpdate.GetSessionIdentity())
|
||||
if _, err := s.d.ContinuityManager().Pause(r.Context(), w, r, key, cookieStore, opts...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if ctxUpdate.Flow != nil {
|
||||
ctxUpdate.Flow.UI.ResetMessages()
|
||||
ctxUpdate.Flow.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
625
selfservice/strategy/lookup/settings_test.go
Normal file
625
selfservice/strategy/lookup/settings_test.go
Normal file
@@ -0,0 +1,625 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package lookup_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/x/configx"
|
||||
"github.com/ory/x/snapshotx"
|
||||
|
||||
"github.com/ory/kratos/driver"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/pkg"
|
||||
kratos "github.com/ory/kratos/pkg/httpclient"
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/selfservice/flow/settings"
|
||||
"github.com/ory/kratos/selfservice/strategy/lookup"
|
||||
"github.com/ory/kratos/text"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
"github.com/ory/x/assertx"
|
||||
"github.com/ory/x/sqlcon"
|
||||
"github.com/ory/x/sqlxx"
|
||||
)
|
||||
|
||||
func createIdentityWithoutLookup(t *testing.T, reg driver.Registry) *identity.Identity {
|
||||
id, _ := createIdentity(t, reg)
|
||||
delete(id.Credentials, identity.CredentialsTypeLookup)
|
||||
require.NoError(t, reg.PrivilegedIdentityPool().UpdateIdentity(context.Background(), id))
|
||||
return id
|
||||
}
|
||||
|
||||
func createIdentity(t *testing.T, reg driver.Registry) (*identity.Identity, []identity.RecoveryCode) {
|
||||
codes := make([]identity.RecoveryCode, 12)
|
||||
for k := range codes {
|
||||
var usedAt sqlxx.NullTime
|
||||
if k%3 == 1 {
|
||||
usedAt = sqlxx.NullTime(time.Unix(int64(1629199958+k), 0))
|
||||
}
|
||||
codes[k] = identity.RecoveryCode{Code: fmt.Sprintf("key-%d", k), UsedAt: usedAt}
|
||||
}
|
||||
identifier := x.NewUUID().String() + "@ory.sh"
|
||||
password := x.NewUUID().String()
|
||||
p, err := reg.Hasher(context.Background()).Generate(context.Background(), []byte(password))
|
||||
require.NoError(t, err)
|
||||
i := &identity.Identity{
|
||||
Traits: identity.Traits(fmt.Sprintf(`{"subject":"%s"}`, identifier)),
|
||||
VerifiableAddresses: []identity.VerifiableAddress{
|
||||
{
|
||||
Value: identifier,
|
||||
Verified: false,
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rc, err := json.Marshal(&identity.CredentialsLookupConfig{RecoveryCodes: codes})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i))
|
||||
i.Credentials = map[identity.CredentialsType]identity.Credentials{
|
||||
identity.CredentialsTypePassword: {
|
||||
Type: identity.CredentialsTypePassword,
|
||||
Identifiers: []string{identifier},
|
||||
Config: sqlxx.JSONRawMessage(`{"hashed_password":"` + string(p) + `"}`),
|
||||
},
|
||||
identity.CredentialsTypeLookup: {
|
||||
Type: identity.CredentialsTypeLookup,
|
||||
Identifiers: []string{i.ID.String()},
|
||||
Config: rc,
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, reg.PrivilegedIdentityPool().UpdateIdentity(context.Background(), i))
|
||||
return i, codes
|
||||
}
|
||||
|
||||
// createIdentityWithImportedLookupViaCreate mimics the admin identity import
|
||||
// path (`POST /admin/identities` with `credentials.lookup_secret`): it
|
||||
// builds an Identity with both password and lookup-secret credentials
|
||||
// populated upfront, leaves `Identifiers` empty on the lookup-secret
|
||||
// credential exactly like `Handler.importLookupSecretCredentials` does,
|
||||
// and persists it via `Manager.Create`. The persister is expected to
|
||||
// default the lookup-secret identifier to the persisted identity ID. See
|
||||
// https://github.com/ory/kratos/issues/4561.
|
||||
func createIdentityWithImportedLookupViaCreate(ctx context.Context, t *testing.T, reg driver.Registry) (*identity.Identity, []identity.RecoveryCode) {
|
||||
codes := make([]identity.RecoveryCode, 4)
|
||||
for k := range codes {
|
||||
codes[k] = identity.RecoveryCode{Code: fmt.Sprintf("imported-key-%d", k)}
|
||||
}
|
||||
identifier := x.NewUUID().String() + "@ory.sh"
|
||||
password := x.NewUUID().String()
|
||||
p, err := reg.Hasher(ctx).Generate(ctx, []byte(password))
|
||||
require.NoError(t, err)
|
||||
rc, err := json.Marshal(&identity.CredentialsLookupConfig{RecoveryCodes: codes})
|
||||
require.NoError(t, err)
|
||||
|
||||
i := &identity.Identity{
|
||||
Traits: identity.Traits(fmt.Sprintf(`{"subject":"%s"}`, identifier)),
|
||||
VerifiableAddresses: []identity.VerifiableAddress{
|
||||
{Value: identifier, Verified: false, CreatedAt: time.Now()},
|
||||
},
|
||||
Credentials: map[identity.CredentialsType]identity.Credentials{
|
||||
identity.CredentialsTypePassword: {
|
||||
Type: identity.CredentialsTypePassword,
|
||||
Identifiers: []string{identifier},
|
||||
Config: sqlxx.JSONRawMessage(`{"hashed_password":"` + string(p) + `"}`),
|
||||
},
|
||||
identity.CredentialsTypeLookup: {
|
||||
// Identifiers intentionally empty; the import path does not
|
||||
// know the persisted identity ID yet.
|
||||
Type: identity.CredentialsTypeLookup,
|
||||
Config: rc,
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, i.SetAvailableAAL(ctx, reg.IdentityManager()))
|
||||
require.NoError(t, reg.IdentityManager().Create(ctx, i))
|
||||
return i, codes
|
||||
}
|
||||
|
||||
// createIdentityWithImportedLookupViaUpdate mimics the admin identity
|
||||
// import path on an existing identity (`PUT /admin/identities/{id}` with
|
||||
// `credentials.lookup_secret`): it first creates an identity with a
|
||||
// password credential, then attaches a lookup-secret credential via
|
||||
// `Manager.Update` with `Identifiers` left empty. The persister is
|
||||
// expected to default the lookup-secret identifier to the existing
|
||||
// identity ID. See https://github.com/ory/kratos/issues/4561.
|
||||
func createIdentityWithImportedLookupViaUpdate(ctx context.Context, t *testing.T, reg driver.Registry) (*identity.Identity, []identity.RecoveryCode) {
|
||||
identifier := x.NewUUID().String() + "@ory.sh"
|
||||
password := x.NewUUID().String()
|
||||
p, err := reg.Hasher(ctx).Generate(ctx, []byte(password))
|
||||
require.NoError(t, err)
|
||||
|
||||
i := &identity.Identity{
|
||||
Traits: identity.Traits(fmt.Sprintf(`{"subject":"%s"}`, identifier)),
|
||||
VerifiableAddresses: []identity.VerifiableAddress{
|
||||
{Value: identifier, Verified: false, CreatedAt: time.Now()},
|
||||
},
|
||||
Credentials: map[identity.CredentialsType]identity.Credentials{
|
||||
identity.CredentialsTypePassword: {
|
||||
Type: identity.CredentialsTypePassword,
|
||||
Identifiers: []string{identifier},
|
||||
Config: sqlxx.JSONRawMessage(`{"hashed_password":"` + string(p) + `"}`),
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, i.SetAvailableAAL(ctx, reg.IdentityManager()))
|
||||
require.NoError(t, reg.IdentityManager().Create(ctx, i))
|
||||
|
||||
codes := make([]identity.RecoveryCode, 4)
|
||||
for k := range codes {
|
||||
codes[k] = identity.RecoveryCode{Code: fmt.Sprintf("imported-key-%d", k)}
|
||||
}
|
||||
rc, err := json.Marshal(&identity.CredentialsLookupConfig{RecoveryCodes: codes})
|
||||
require.NoError(t, err)
|
||||
i.Credentials[identity.CredentialsTypeLookup] = identity.Credentials{
|
||||
// Identifiers intentionally empty; the persister must default it.
|
||||
Type: identity.CredentialsTypeLookup,
|
||||
Config: rc,
|
||||
}
|
||||
require.NoError(t, i.SetAvailableAAL(ctx, reg.IdentityManager()))
|
||||
require.NoError(t, reg.IdentityManager().Update(ctx, i, identity.ManagerAllowWriteProtectedTraits))
|
||||
return i, codes
|
||||
}
|
||||
|
||||
func TestCompleteSettings(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t,
|
||||
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypePassword, false)),
|
||||
configx.WithValues(testhelpers.MethodEnableConfig("profile", false)),
|
||||
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypeLookup, true)),
|
||||
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/login.schema.json")),
|
||||
configx.WithValues(map[string]any{
|
||||
config.ViperKeySelfServiceSettingsRequiredAAL: "aal1",
|
||||
config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter: "1m",
|
||||
}),
|
||||
)
|
||||
|
||||
publicTS, _ := testhelpers.NewKratosServer(t, reg)
|
||||
|
||||
errTS := testhelpers.NewErrorTestServer(t, reg)
|
||||
uiTS := testhelpers.NewSettingsUIFlowEchoServer(t, reg)
|
||||
_ = testhelpers.NewRedirSessionEchoTS(t, reg)
|
||||
loginTS := testhelpers.NewLoginUIFlowEchoServer(t, reg)
|
||||
|
||||
doAPIFlow := func(t *testing.T, v func(url.Values), id *identity.Identity) (string, *http.Response) {
|
||||
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id)
|
||||
f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, publicTS)
|
||||
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
||||
v(values)
|
||||
payload := testhelpers.EncodeFormAsJSON(t, true, values)
|
||||
return testhelpers.SettingsMakeRequest(t, true, false, f, apiClient, payload)
|
||||
}
|
||||
|
||||
doBrowserFlow := func(t *testing.T, spa bool, v func(url.Values), id *identity.Identity) (string, *http.Response) {
|
||||
browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(ctx, t, reg, id)
|
||||
f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, spa, publicTS)
|
||||
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
||||
v(values)
|
||||
return testhelpers.SettingsMakeRequest(t, false, spa, f, browserClient, testhelpers.EncodeFormAsJSON(t, spa, values))
|
||||
}
|
||||
|
||||
t.Run("case=hide recovery codes behind reveal button and show disable button", func(t *testing.T) {
|
||||
id, _ := createIdentity(t, reg)
|
||||
browserClient := testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id)
|
||||
|
||||
t.Run("case=spa", func(t *testing.T) {
|
||||
f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, true, publicTS)
|
||||
snapshotx.SnapshotT(t, f.Ui.Nodes, snapshotx.ExceptPaths("0.attributes.value"))
|
||||
})
|
||||
|
||||
t.Run("case=browser", func(t *testing.T) {
|
||||
f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, false, publicTS)
|
||||
snapshotx.SnapshotT(t, f.Ui.Nodes, snapshotx.ExceptPaths("0.attributes.value"))
|
||||
})
|
||||
|
||||
t.Run("case=api", func(t *testing.T) {
|
||||
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id)
|
||||
f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, publicTS)
|
||||
snapshotx.SnapshotT(t, f.Ui.Nodes, snapshotx.ExceptPaths("0.attributes.value"))
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=button for regeneration is displayed when identity has no recovery codes yet", func(t *testing.T) {
|
||||
id := createIdentityWithoutLookup(t, reg)
|
||||
browserClient := testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id)
|
||||
|
||||
t.Run("case=spa", func(t *testing.T) {
|
||||
f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, true, publicTS)
|
||||
testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{"0.attributes.value"})
|
||||
})
|
||||
|
||||
t.Run("case=browser", func(t *testing.T) {
|
||||
f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, false, publicTS)
|
||||
testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{"0.attributes.value"})
|
||||
})
|
||||
|
||||
t.Run("case=api", func(t *testing.T) {
|
||||
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id)
|
||||
f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, publicTS)
|
||||
testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{"0.attributes.value"})
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=should pass without csrf if API flow", func(t *testing.T) {
|
||||
id, _ := createIdentity(t, reg)
|
||||
|
||||
body, res := doAPIFlow(t, func(v url.Values) {
|
||||
v.Del("csrf_token")
|
||||
v.Set(node.LookupReveal, "true")
|
||||
}, id)
|
||||
|
||||
assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow)
|
||||
testhelpers.SnapshotTExcept(t, json.RawMessage(gjson.Get(body, "ui.nodes").Raw), []string{"0.attributes.value"})
|
||||
})
|
||||
|
||||
t.Run("case=should fail if CSRF token is invalid", func(t *testing.T) {
|
||||
id := createIdentityWithoutLookup(t, reg)
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
body, res := doBrowserFlow(t, false, func(v url.Values) {
|
||||
v.Del("csrf_token")
|
||||
v.Set(node.LookupReveal, "true")
|
||||
}, id)
|
||||
|
||||
assert.Contains(t, res.Request.URL.String(), errTS.URL)
|
||||
assert.Equal(t, nosurfx.ErrInvalidCSRFToken().Reason(), gjson.Get(body, "reason").String(), body)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
body, res := doBrowserFlow(t, true, func(v url.Values) {
|
||||
v.Del("csrf_token")
|
||||
v.Set(node.LookupReveal, "true")
|
||||
}, id)
|
||||
|
||||
assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow)
|
||||
assert.Equal(t, nosurfx.ErrInvalidCSRFToken().Reason(), gjson.Get(body, "error.reason").String(), body)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("type=can not reveal or regenerate or remove without privileged session", func(t *testing.T) {
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, "1ns")
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, "5m")
|
||||
})
|
||||
|
||||
id, codes := createIdentity(t, reg)
|
||||
|
||||
checkIdentity := func(t *testing.T) {
|
||||
_, cred, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(context.Background(), identity.CredentialsTypeLookup, id.ID.String())
|
||||
require.NoError(t, err)
|
||||
assertx.EqualAsJSON(t, codes, json.RawMessage(gjson.GetBytes(cred.Config, "recovery_codes").Raw))
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
v func(v url.Values)
|
||||
d string
|
||||
}{
|
||||
{
|
||||
d: "reveal",
|
||||
v: func(v url.Values) {
|
||||
v.Set(node.LookupReveal, "true")
|
||||
},
|
||||
},
|
||||
{
|
||||
d: "regenerate",
|
||||
v: func(v url.Values) {
|
||||
v.Set(node.LookupRegenerate, "true")
|
||||
},
|
||||
},
|
||||
{
|
||||
d: "disable",
|
||||
v: func(v url.Values) {
|
||||
v.Set(node.LookupDisable, "true")
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run("case="+tc.d, func(t *testing.T) {
|
||||
payload := tc.v
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
actual, res := doAPIFlow(t, payload, id)
|
||||
assert.Equal(t, http.StatusForbidden, res.StatusCode)
|
||||
assert.Contains(t, gjson.Get(actual, "redirect_browser_to").String(), publicTS.URL+"/self-service/login/browser?refresh=true&return_to=")
|
||||
assertx.EqualAsJSONExcept(t, settings.NewFlowNeedsReAuth(), json.RawMessage(actual), []string{"redirect_browser_to"})
|
||||
checkIdentity(t)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
actual, res := doBrowserFlow(t, true, payload, id)
|
||||
assert.Equal(t, http.StatusForbidden, res.StatusCode)
|
||||
assert.Contains(t, gjson.Get(actual, "redirect_browser_to").String(), publicTS.URL+"/self-service/login/browser?refresh=true&return_to=")
|
||||
assertx.EqualAsJSONExcept(t, settings.NewFlowNeedsReAuth(), json.RawMessage(actual), []string{"redirect_browser_to"})
|
||||
checkIdentity(t)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
actual, res := doBrowserFlow(t, false, payload, id)
|
||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||
assert.Contains(t, res.Request.URL.String(), loginTS.URL+"/login-ts")
|
||||
assertx.EqualAsJSON(t, text.NewInfoLoginReAuth().Text, json.RawMessage(gjson.Get(actual, "ui.messages.0.text").Raw), actual)
|
||||
checkIdentity(t)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("type=can not confirm without regenerate", func(t *testing.T) {
|
||||
id, codes := createIdentity(t, reg)
|
||||
payload := func(v url.Values) {
|
||||
v.Set(node.LookupConfirm, "true")
|
||||
}
|
||||
|
||||
const reason = "You must (re-)generate recovery backup codes before you can save them."
|
||||
|
||||
checkIdentity := func(t *testing.T) {
|
||||
_, cred, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(context.Background(), identity.CredentialsTypeLookup, id.ID.String())
|
||||
require.NoError(t, err)
|
||||
assertx.EqualAsJSON(t, codes, json.RawMessage(gjson.GetBytes(cred.Config, "recovery_codes").Raw))
|
||||
}
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
actual, res := doAPIFlow(t, payload, id)
|
||||
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
|
||||
assert.EqualValues(t, reason, gjson.Get(actual, "ui.messages.0.text").String(), "%s", actual)
|
||||
checkIdentity(t)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
actual, res := doBrowserFlow(t, true, payload, id)
|
||||
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
|
||||
assert.EqualValues(t, reason, gjson.Get(actual, "ui.messages.0.text").String(), "%s", actual)
|
||||
checkIdentity(t)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
actual, res := doBrowserFlow(t, false, payload, id)
|
||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||
assert.Contains(t, res.Request.URL.String(), uiTS.URL)
|
||||
assert.EqualValues(t, reason, gjson.Get(actual, "ui.messages.0.text").String(), "%s", actual)
|
||||
checkIdentity(t)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("type=regenerate but no confirmation", func(t *testing.T) {
|
||||
id, codes := createIdentity(t, reg)
|
||||
payload := func(v url.Values) {
|
||||
v.Set(node.LookupRegenerate, "true")
|
||||
}
|
||||
|
||||
checkIdentity := func(t *testing.T) {
|
||||
_, cred, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(context.Background(), identity.CredentialsTypeLookup, id.ID.String())
|
||||
require.NoError(t, err)
|
||||
assertx.EqualAsJSON(t, codes, json.RawMessage(gjson.GetBytes(cred.Config, "recovery_codes").Raw))
|
||||
}
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
actual, res := doAPIFlow(t, payload, id)
|
||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||
testhelpers.SnapshotTExcept(t, json.RawMessage(gjson.Get(actual, "ui.nodes").Raw), []string{"0.attributes.value"})
|
||||
checkIdentity(t)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
actual, res := doBrowserFlow(t, true, payload, id)
|
||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||
testhelpers.SnapshotTExcept(t, json.RawMessage(gjson.Get(actual, "ui.nodes").Raw), []string{"0.attributes.value"})
|
||||
checkIdentity(t)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
actual, res := doBrowserFlow(t, false, payload, id)
|
||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||
assert.Contains(t, res.Request.URL.String(), uiTS.URL)
|
||||
testhelpers.SnapshotTExcept(t, json.RawMessage(gjson.Get(actual, "ui.nodes").Raw), []string{"0.attributes.value"})
|
||||
checkIdentity(t)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("type=regenerate with confirmation", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
d string
|
||||
c func(t *testing.T) *identity.Identity
|
||||
}{
|
||||
{
|
||||
d: "with",
|
||||
c: func(t *testing.T) *identity.Identity {
|
||||
i, _ := createIdentity(t, reg)
|
||||
return i
|
||||
},
|
||||
},
|
||||
{
|
||||
d: "without",
|
||||
c: func(t *testing.T) *identity.Identity {
|
||||
return createIdentityWithoutLookup(t, reg)
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run("credentials="+tc.d, func(t *testing.T) {
|
||||
payload := func(v url.Values) {
|
||||
v.Del(node.LookupReveal)
|
||||
v.Del(node.LookupDisable)
|
||||
v.Set(node.LookupRegenerate, "true")
|
||||
}
|
||||
|
||||
payloadConfirm := func(v url.Values) {
|
||||
v.Del(node.LookupRegenerate)
|
||||
v.Del(node.LookupDisable)
|
||||
v.Del(node.LookupReveal)
|
||||
v.Set(node.LookupConfirm, "true")
|
||||
}
|
||||
|
||||
checkIdentity := func(t *testing.T, id *identity.Identity, f *kratos.SettingsFlow) {
|
||||
_, cred, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(context.Background(), identity.CredentialsTypeLookup, id.ID.String())
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, gjson.GetBytes(cred.Config, "recovery_codes").Raw, "key-1")
|
||||
assert.NotContains(t, gjson.GetBytes(cred.Config, "recovery_codes").Raw, "key-0")
|
||||
|
||||
actualFlow, err := reg.SettingsFlowPersister().GetSettingsFlow(context.Background(), uuid.FromStringOrNil(f.Id))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, gjson.GetBytes(actualFlow.InternalContext, flow.PrefixInternalContextKey(identity.CredentialsTypeLookup, lookup.InternalContextKeyRegenerated)))
|
||||
}
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
id, _ := createIdentity(t, reg)
|
||||
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id)
|
||||
f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, publicTS)
|
||||
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
||||
|
||||
payload(values)
|
||||
_, _ = testhelpers.SettingsMakeRequest(t, true, false, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, values))
|
||||
|
||||
payloadConfirm(values)
|
||||
actual, res := testhelpers.SettingsMakeRequest(t, true, false, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, values))
|
||||
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||
|
||||
assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow)
|
||||
assert.EqualValues(t, flow.StateSuccess, json.RawMessage(gjson.Get(actual, "state").String()))
|
||||
|
||||
checkIdentity(t, id, f)
|
||||
testhelpers.EnsureAAL(t, apiClient, publicTS, "aal2", string(identity.CredentialsTypeLookup))
|
||||
})
|
||||
|
||||
runBrowser := func(t *testing.T, spa bool) {
|
||||
id, _ := createIdentity(t, reg)
|
||||
|
||||
browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(ctx, t, reg, id)
|
||||
f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, spa, publicTS)
|
||||
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
||||
|
||||
payload(values)
|
||||
_, _ = testhelpers.SettingsMakeRequest(t, false, spa, f, browserClient, testhelpers.EncodeFormAsJSON(t, spa, values))
|
||||
|
||||
payloadConfirm(values)
|
||||
actual, res := testhelpers.SettingsMakeRequest(t, false, spa, f, browserClient, testhelpers.EncodeFormAsJSON(t, spa, values))
|
||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||
|
||||
if spa {
|
||||
assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow)
|
||||
assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(actual, "continue_with.0.action").String(), "%s", actual)
|
||||
assert.Contains(t, gjson.Get(actual, "continue_with.0.redirect_browser_to").String(), uiTS.URL, "%s", actual)
|
||||
} else {
|
||||
assert.Contains(t, res.Request.URL.String(), uiTS.URL)
|
||||
assert.Empty(t, gjson.Get(actual, "continue_with").Array(), "%s", actual)
|
||||
}
|
||||
|
||||
assert.EqualValues(t, flow.StateSuccess, json.RawMessage(gjson.Get(actual, "state").String()))
|
||||
checkIdentity(t, id, f)
|
||||
testhelpers.EnsureAAL(t, browserClient, publicTS, "aal2", string(identity.CredentialsTypeLookup))
|
||||
}
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
runBrowser(t, false)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
runBrowser(t, true)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("type=remove lookup codes", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
d string
|
||||
c func(t *testing.T) *identity.Identity
|
||||
}{
|
||||
{
|
||||
d: "with",
|
||||
c: func(t *testing.T) *identity.Identity {
|
||||
i, _ := createIdentity(t, reg)
|
||||
return i
|
||||
},
|
||||
},
|
||||
{
|
||||
d: "without",
|
||||
c: func(t *testing.T) *identity.Identity {
|
||||
return createIdentityWithoutLookup(t, reg)
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run("credentials="+tc.d, func(t *testing.T) {
|
||||
payloadConfirm := func(v url.Values) {
|
||||
v.Del(node.LookupRegenerate)
|
||||
v.Del(node.LookupReveal)
|
||||
v.Set(node.LookupDisable, "true")
|
||||
}
|
||||
|
||||
checkIdentity := func(t *testing.T, id *identity.Identity, f *kratos.SettingsFlow) {
|
||||
_, _, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(context.Background(), identity.CredentialsTypeLookup, id.ID.String())
|
||||
require.ErrorIs(t, err, sqlcon.ErrNoRows())
|
||||
|
||||
actualFlow, err := reg.SettingsFlowPersister().GetSettingsFlow(context.Background(), uuid.FromStringOrNil(f.Id))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, gjson.GetBytes(actualFlow.InternalContext, flow.PrefixInternalContextKey(identity.CredentialsTypeLookup, lookup.InternalContextKeyRegenerated)).Raw)
|
||||
}
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
id, _ := createIdentity(t, reg)
|
||||
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id)
|
||||
f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, publicTS)
|
||||
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
||||
|
||||
payloadConfirm(values)
|
||||
actual, res := testhelpers.SettingsMakeRequest(t, true, false, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, values))
|
||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||
|
||||
assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow)
|
||||
assert.EqualValues(t, flow.StateSuccess, json.RawMessage(gjson.Get(actual, "state").String()))
|
||||
|
||||
checkIdentity(t, id, f)
|
||||
testhelpers.EnsureAAL(t, apiClient, publicTS, "aal1")
|
||||
})
|
||||
|
||||
runBrowser := func(t *testing.T, spa bool) {
|
||||
id, _ := createIdentity(t, reg)
|
||||
|
||||
browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(ctx, t, reg, id)
|
||||
f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, spa, publicTS)
|
||||
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
||||
|
||||
payloadConfirm(values)
|
||||
actual, res := testhelpers.SettingsMakeRequest(t, false, spa, f, browserClient, testhelpers.EncodeFormAsJSON(t, spa, values))
|
||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||
|
||||
if spa {
|
||||
assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow)
|
||||
assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(actual, "continue_with.0.action").String(), "%s", actual)
|
||||
assert.Contains(t, gjson.Get(actual, "continue_with.0.redirect_browser_to").String(), uiTS.URL, "%s", actual)
|
||||
} else {
|
||||
assert.Contains(t, res.Request.URL.String(), uiTS.URL)
|
||||
assert.Empty(t, gjson.Get(actual, "continue_with").Array(), "%s", actual)
|
||||
}
|
||||
|
||||
assert.EqualValues(t, flow.StateSuccess, json.RawMessage(gjson.Get(actual, "state").String()))
|
||||
checkIdentity(t, id, f)
|
||||
testhelpers.EnsureAAL(t, browserClient, publicTS, "aal1")
|
||||
}
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
runBrowser(t, false)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
runBrowser(t, true)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
126
selfservice/strategy/lookup/strategy.go
Normal file
126
selfservice/strategy/lookup/strategy.go
Normal file
@@ -0,0 +1,126 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package lookup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/kratos/continuity"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/hash"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/selfservice/errorx"
|
||||
"github.com/ory/kratos/selfservice/flow/login"
|
||||
"github.com/ory/kratos/selfservice/flow/registration"
|
||||
"github.com/ory/kratos/selfservice/flow/settings"
|
||||
"github.com/ory/kratos/session"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/kratos/x/transaction"
|
||||
"github.com/ory/x/httpx"
|
||||
"github.com/ory/x/logrusx"
|
||||
"github.com/ory/x/otelx"
|
||||
)
|
||||
|
||||
// var _ login.Strategy = new(Strategy)
|
||||
var (
|
||||
_ settings.Strategy = (*Strategy)(nil)
|
||||
_ login.AAL2FormHydrator = (*Strategy)(nil)
|
||||
_ identity.ActiveCredentialsCounter = (*Strategy)(nil)
|
||||
)
|
||||
|
||||
type dependencies interface {
|
||||
logrusx.Provider
|
||||
httpx.WriterProvider
|
||||
nosurfx.CSRFTokenGeneratorProvider
|
||||
nosurfx.CSRFProvider
|
||||
transaction.PersistenceProvider
|
||||
otelx.Provider
|
||||
|
||||
config.Provider
|
||||
|
||||
continuity.ManagementProvider
|
||||
|
||||
x.CookieProvider
|
||||
|
||||
errorx.ManagementProvider
|
||||
hash.HashProvider
|
||||
|
||||
registration.HandlerProvider
|
||||
registration.HooksProvider
|
||||
registration.ErrorHandlerProvider
|
||||
registration.HookExecutorProvider
|
||||
registration.FlowPersistenceProvider
|
||||
|
||||
login.HooksProvider
|
||||
login.ErrorHandlerProvider
|
||||
login.HookExecutorProvider
|
||||
login.FlowPersistenceProvider
|
||||
login.HandlerProvider
|
||||
|
||||
settings.FlowPersistenceProvider
|
||||
settings.HookExecutorProvider
|
||||
settings.HooksProvider
|
||||
settings.ErrorHandlerProvider
|
||||
|
||||
identity.PrivilegedPoolProvider
|
||||
identity.ValidationProvider
|
||||
identity.ManagementProvider
|
||||
|
||||
session.HandlerProvider
|
||||
session.ManagementProvider
|
||||
}
|
||||
|
||||
type Strategy struct{ d dependencies }
|
||||
|
||||
func NewStrategy(d dependencies) *Strategy { return &Strategy{d: d} }
|
||||
|
||||
func (s *Strategy) CountActiveFirstFactorCredentials(_ context.Context, _ map[identity.CredentialsType]identity.Credentials) (count int, err error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *Strategy) CountActiveMultiFactorCredentials(_ context.Context, cc map[identity.CredentialsType]identity.Credentials) (count int, err error) {
|
||||
for _, c := range cc {
|
||||
if c.Type == s.ID() && len(c.Config) > 0 {
|
||||
var conf identity.CredentialsLookupConfig
|
||||
if err := json.Unmarshal(c.Config, &conf); err != nil {
|
||||
return 0, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if len(conf.RecoveryCodes) > 0 {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Strategy) ID() identity.CredentialsType {
|
||||
return identity.CredentialsTypeLookup
|
||||
}
|
||||
|
||||
func (s *Strategy) NodeGroup() node.UiNodeGroup {
|
||||
return node.LookupGroup
|
||||
}
|
||||
|
||||
func (s *Strategy) CompletedAuthenticationMethod(ctx context.Context) session.AuthenticationMethod {
|
||||
return session.AuthenticationMethod{
|
||||
Method: s.ID(),
|
||||
AAL: identity.AuthenticatorAssuranceLevel2,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Strategy) PopulateLoginMethodSecondFactor(r *http.Request, f *login.Flow) error {
|
||||
return s.PopulateLoginMethod(r, identity.AuthenticatorAssuranceLevel2, f)
|
||||
}
|
||||
|
||||
func (s *Strategy) PopulateLoginMethodSecondFactorRefresh(r *http.Request, f *login.Flow) error {
|
||||
return s.PopulateLoginMethod(r, identity.AuthenticatorAssuranceLevel2, f)
|
||||
}
|
||||
75
selfservice/strategy/lookup/strategy_test.go
Normal file
75
selfservice/strategy/lookup/strategy_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package lookup_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/kratos/selfservice/strategy/lookup"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/pkg"
|
||||
)
|
||||
|
||||
func TestCountActiveFirstFactorCredentials(t *testing.T) {
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
strategy := lookup.NewStrategy(reg)
|
||||
|
||||
t.Run("first factor", func(t *testing.T) {
|
||||
actual, err := strategy.CountActiveFirstFactorCredentials(t.Context(), nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, actual)
|
||||
})
|
||||
|
||||
t.Run("multi factor", func(t *testing.T) {
|
||||
for k, tc := range []struct {
|
||||
in map[identity.CredentialsType]identity.Credentials
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
|
||||
Type: strategy.ID(),
|
||||
Config: []byte{},
|
||||
}},
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
|
||||
Type: strategy.ID(),
|
||||
Config: []byte(`{"recovery_codes": []}`),
|
||||
}},
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
|
||||
Type: strategy.ID(),
|
||||
Identifiers: []string{"foo"},
|
||||
Config: []byte(`{"recovery_codes": [{}]}`),
|
||||
}},
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
|
||||
Type: strategy.ID(),
|
||||
Config: []byte(`{}`),
|
||||
}},
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
in: nil,
|
||||
expected: 0,
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
|
||||
actual, err := strategy.CountActiveMultiFactorCredentials(t.Context(), tc.in)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, actual)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
11
selfservice/strategy/lookup/stub/login.schema.json
Normal file
11
selfservice/strategy/lookup/stub/login.schema.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$id": "https://example.com/person.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user