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:
67
selfservice/strategy/password/.schema/login.schema.json
Normal file
67
selfservice/strategy/password/.schema/login.schema.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"$id": "https://schemas.ory.sh/kratos/selfservice/strategy/password/login.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"csrf_token": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"identifier": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"method": {
|
||||
"type": "string"
|
||||
},
|
||||
"transient_payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"method": {
|
||||
"const": "password"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"password"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"password_identifier": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"password_identifier"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"password_identifier"
|
||||
]
|
||||
},
|
||||
"else": {
|
||||
"required": [
|
||||
"identifier"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$id": "https://schemas.ory.sh/kratos/selfservice/strategy/password/registration.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"csrf_token": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"traits": {
|
||||
"description": "This field will be overwritten in registration.go's decoder() method. Do not add anything to this field as it has no effect."
|
||||
},
|
||||
"method": {
|
||||
"type": "string"
|
||||
},
|
||||
"transient_payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"if": {
|
||||
"properties": {
|
||||
"method": {
|
||||
"const": "password"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"password"
|
||||
]
|
||||
}
|
||||
}
|
||||
24
selfservice/strategy/password/.schema/settings.schema.json
Normal file
24
selfservice/strategy/password/.schema/settings.schema.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$id": "https://schemas.ory.sh/kratos/selfservice/strategy/password/settings.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"password"
|
||||
],
|
||||
"properties": {
|
||||
"csrf_token": {
|
||||
"type": "string"
|
||||
},
|
||||
"method": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"transient_payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1070004,
|
||||
"text": "ID",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "password",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"autocomplete": "current-password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1070001,
|
||||
"text": "Password",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1010022,
|
||||
"text": "Sign in with password",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,67 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "identifier",
|
||||
"type": "hidden",
|
||||
"value": "some@user.com",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "password",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"autocomplete": "current-password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1070001,
|
||||
"text": "Password",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1010001,
|
||||
"text": "Sign in",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
null
|
||||
@@ -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": "password",
|
||||
"attributes": {
|
||||
"name": "password",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"autocomplete": "current-password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1070001,
|
||||
"text": "Password",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1010022,
|
||||
"text": "Sign in with password",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
null
|
||||
@@ -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": "password",
|
||||
"attributes": {
|
||||
"name": "password",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"autocomplete": "current-password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1070001,
|
||||
"text": "Password",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1010022,
|
||||
"text": "Sign in with password",
|
||||
"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": "password",
|
||||
"attributes": {
|
||||
"name": "password",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"autocomplete": "current-password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1070001,
|
||||
"text": "Password",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1010022,
|
||||
"text": "Sign in with password",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
null
|
||||
@@ -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": "password",
|
||||
"attributes": {
|
||||
"name": "password",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"autocomplete": "current-password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1070001,
|
||||
"text": "Password",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1010022,
|
||||
"text": "Sign in with password",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
null
|
||||
@@ -0,0 +1 @@
|
||||
null
|
||||
@@ -0,0 +1 @@
|
||||
null
|
||||
@@ -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": "password",
|
||||
"attributes": {
|
||||
"name": "password",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"autocomplete": "new-password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1070001,
|
||||
"text": "Password",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1040001,
|
||||
"text": "Sign up",
|
||||
"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": "password",
|
||||
"attributes": {
|
||||
"name": "password",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"autocomplete": "new-password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1070001,
|
||||
"text": "Password",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1040001,
|
||||
"text": "Sign up",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
}
|
||||
]
|
||||
@@ -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": "password",
|
||||
"attributes": {
|
||||
"name": "password",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"autocomplete": "new-password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1070001,
|
||||
"text": "Password",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1040001,
|
||||
"text": "Sign up",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
}
|
||||
]
|
||||
@@ -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": "password",
|
||||
"attributes": {
|
||||
"name": "password",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"autocomplete": "new-password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1070001,
|
||||
"text": "Password",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1040001,
|
||||
"text": "Sign up",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
14
selfservice/strategy/password/helpers.go
Normal file
14
selfservice/strategy/password/helpers.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password
|
||||
|
||||
import "net/url"
|
||||
|
||||
func tidyForm(vv url.Values) url.Values {
|
||||
for _, k := range []string{"password", "csrf_token", "flow"} {
|
||||
vv.Del(k)
|
||||
}
|
||||
|
||||
return vv
|
||||
}
|
||||
20
selfservice/strategy/password/helpers_test.go
Normal file
20
selfservice/strategy/password/helpers_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTidyForm(t *testing.T) {
|
||||
assert.EqualValues(t, url.Values{"foobar": {"foo"}}, tidyForm(url.Values{
|
||||
"password": {"some-value"},
|
||||
"csrf_token": {"some-value"},
|
||||
"flow": {"some-value"},
|
||||
"foobar": {"foo"},
|
||||
}))
|
||||
}
|
||||
244
selfservice/strategy/password/login.go
Normal file
244
selfservice/strategy/password/login.go
Normal file
@@ -0,0 +1,244 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
|
||||
"github.com/ory/x/otelx"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/kratos/hash"
|
||||
"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/selfservice/flowhelpers"
|
||||
"github.com/ory/kratos/selfservice/hook"
|
||||
"github.com/ory/kratos/selfservice/strategy/idfirst"
|
||||
"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"
|
||||
)
|
||||
|
||||
var _ login.AAL1FormHydrator = new(Strategy)
|
||||
|
||||
func (s *Strategy) handleLoginError(r *http.Request, f *login.Flow, payload updateLoginFlowWithPasswordMethod, err error) error {
|
||||
if f != nil {
|
||||
f.UI.Nodes.ResetNodes("password")
|
||||
f.UI.Nodes.SetValueAttribute("identifier", cmp.Or(payload.Identifier, payload.LegacyIdentifier))
|
||||
if f.Type == flow.TypeBrowser {
|
||||
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, _ *session.Session) (i *identity.Identity, err error) {
|
||||
ctx, span := s.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.strategy.password.Strategy.Login")
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
if err := login.CheckAAL(f, identity.AuthenticatorAssuranceLevel1); err != nil {
|
||||
span.SetAttributes(attribute.String("not_responsible_reason", "requested AAL is not AAL1"))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := flow.MethodEnabledAndAllowedFromRequest(r, f.GetFlowName(), s.ID().String(), s.d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var p updateLoginFlowWithPasswordMethod
|
||||
if err := decoderx.Decode(r, &p,
|
||||
decoderx.HTTPDecoderSetValidatePayloads(true),
|
||||
decoderx.MustHTTPRawJSONSchemaCompiler(loginSchema),
|
||||
decoderx.HTTPDecoderJSONFollowsFormFormat()); err != nil {
|
||||
return nil, s.handleLoginError(r, f, p, err)
|
||||
}
|
||||
f.TransientPayload = p.TransientPayload
|
||||
|
||||
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, p, err)
|
||||
}
|
||||
|
||||
identifier := cmp.Or(p.Identifier, p.LegacyIdentifier)
|
||||
i, c, err := s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(ctx, s.ID(), identifier)
|
||||
if err != nil {
|
||||
time.Sleep(x.RandomDelay(s.d.Config().HasherArgon2(ctx).ExpectedDuration, s.d.Config().HasherArgon2(ctx).ExpectedDeviation))
|
||||
return nil, s.handleLoginError(r, f, p, errors.WithStack(schema.NewInvalidCredentialsError()))
|
||||
}
|
||||
|
||||
var o identity.CredentialsPassword
|
||||
d := json.NewDecoder(bytes.NewBuffer(c.Config))
|
||||
if err := d.Decode(&o); err != nil {
|
||||
return nil, x.WrapWithIdentityIDError(errors.WithStack(herodot.ErrInternalServerError().WithReason("The password credentials could not be decoded properly").WithDebug(err.Error()).WithWrap(err)), i.ID)
|
||||
}
|
||||
|
||||
if o.ShouldUsePasswordMigrationHook() {
|
||||
pwHook := s.d.Config().PasswordMigrationHook(ctx)
|
||||
if !pwHook.Enabled {
|
||||
return nil, x.WrapWithIdentityIDError(errors.WithStack(herodot.ErrMisconfiguration().WithReasonf("Password migration hook is not enabled but password migration is requested.")), i.ID)
|
||||
}
|
||||
|
||||
migrationHook := hook.NewPasswordMigrationHook(s.d, &pwHook.Config)
|
||||
err = migrationHook.Execute(ctx, r, f, &hook.PasswordMigrationRequest{
|
||||
Identifier: identifier,
|
||||
Password: p.Password,
|
||||
Identity: i,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, s.handleLoginError(r, f, p, x.WrapWithIdentityIDError(err, i.ID))
|
||||
}
|
||||
|
||||
if err := s.migratePasswordHash(ctx, i.ID, []byte(p.Password)); err != nil {
|
||||
return nil, s.handleLoginError(r, f, p, x.WrapWithIdentityIDError(err, i.ID))
|
||||
}
|
||||
} else {
|
||||
if err := hash.Compare(ctx, []byte(p.Password), []byte(o.HashedPassword)); err != nil {
|
||||
return nil, s.handleLoginError(r, f, p, errors.WithStack(x.WrapWithIdentityIDError(schema.NewInvalidCredentialsError(), i.ID)))
|
||||
}
|
||||
|
||||
if !s.d.Hasher(ctx).Understands([]byte(o.HashedPassword)) {
|
||||
if err := s.migratePasswordHash(ctx, i.ID, []byte(p.Password)); err != nil {
|
||||
s.d.Logger().Warnf("Unable to migrate password hash for identity %s: %s Keeping existing password hash and continuing.", i.ID, x.WrapWithIdentityIDError(err, i.ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
f.Active = s.ID()
|
||||
if err = s.d.LoginFlowPersister().UpdateLoginFlow(ctx, f); err != nil {
|
||||
return nil, s.handleLoginError(r, f, p, errors.WithStack(x.WrapWithIdentityIDError(herodot.ErrInternalServerError().WithReason("Could not update flow").WithDebug(err.Error()), i.ID)))
|
||||
}
|
||||
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func (s *Strategy) migratePasswordHash(ctx context.Context, identifier uuid.UUID, password []byte) (err error) {
|
||||
ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.password.Strategy.migratePasswordHash")
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
hpw, err := s.d.Hasher(ctx).Generate(ctx, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
co, err := json.Marshal(&identity.CredentialsPassword{HashedPassword: string(hpw)})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unable to encode password configuration to JSON")
|
||||
}
|
||||
|
||||
i, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(ctx, identifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c, ok := i.GetCredentials(s.ID())
|
||||
if !ok {
|
||||
return errors.New("expected to find password credential but could not")
|
||||
}
|
||||
|
||||
c.Config = co
|
||||
i.SetCredentials(s.ID(), *c)
|
||||
|
||||
return s.d.IdentityManager().Update(ctx, i, identity.ManagerAllowWriteProtectedTraits)
|
||||
}
|
||||
|
||||
func (s *Strategy) PopulateLoginMethodFirstFactorRefresh(r *http.Request, sr *login.Flow, _ *session.Session) (err error) {
|
||||
ctx := r.Context()
|
||||
ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.password.Strategy.PopulateLoginMethodFirstFactorRefresh")
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
identifier, id, _ := flowhelpers.GuessForcedLoginIdentifier(r, s.d, sr, s.ID())
|
||||
if identifier == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we don't have a password set, do not show the password field.
|
||||
count, err := s.CountActiveFirstFactorCredentials(ctx, id.Credentials)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if count == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
sr.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
||||
sr.UI.SetNode(node.NewInputField("identifier", identifier, node.DefaultGroup, node.InputAttributeTypeHidden))
|
||||
sr.UI.SetNode(NewPasswordNode("password", node.InputAttributeAutocompleteCurrentPassword))
|
||||
sr.UI.GetNodes().Append(node.NewInputField("method", "password", node.PasswordGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoLogin()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Strategy) addIdentifierNode(r *http.Request, sr *login.Flow) error {
|
||||
ds, err := sr.IdentitySchema.URL(r.Context(), s.d.Config())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
identifierLabel, err := login.GetIdentifierLabelFromSchema(r.Context(), ds.String(), s.d.Config().SecurityDisallowRefInIdentitySchemas(r.Context()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sr.UI.SetNode(node.NewInputField("identifier", "", node.DefaultGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute).WithMetaLabel(identifierLabel))
|
||||
// node.WithInputAttributes(func(a *node.InputAttributes) { // TODO: https://github.com/ory-corp/cloud/issues/12416
|
||||
// // Hint password managers to autofill the saved username for this site.
|
||||
// a.Autocomplete = node.InputAttributeAutocompleteUsername
|
||||
// })).WithMetaLabel(identifierLabel))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Strategy) PopulateLoginMethodFirstFactor(r *http.Request, sr *login.Flow) error {
|
||||
if err := s.addIdentifierNode(r, sr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sr.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
||||
sr.UI.SetNode(NewPasswordNode("password", node.InputAttributeAutocompleteCurrentPassword))
|
||||
sr.UI.GetNodes().Append(node.NewInputField("method", "password", node.PasswordGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoLoginPassword()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Strategy) PopulateLoginMethodIdentifierFirstCredentials(r *http.Request, sr *login.Flow, opts ...login.FormHydratorModifier) (err error) {
|
||||
ctx, span := s.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.strategy.password.Strategy.PopulateLoginMethodIdentifierFirstCredentials")
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
o := login.NewFormHydratorOptions(opts)
|
||||
|
||||
var count int
|
||||
if o.IdentityHint != nil {
|
||||
var err error
|
||||
// If we have an identity hint we can perform identity credentials discovery and
|
||||
// hide this credential if it should not be included.
|
||||
if count, err = s.CountActiveFirstFactorCredentials(ctx, o.IdentityHint.Credentials); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if count > 0 || s.d.Config().SecurityAccountEnumerationMitigate(ctx) {
|
||||
sr.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
||||
sr.UI.SetNode(NewPasswordNode("password", node.InputAttributeAutocompleteCurrentPassword))
|
||||
sr.UI.GetNodes().Append(node.NewInputField("method", "password", node.PasswordGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoLoginPassword()))
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return errors.WithStack(idfirst.ErrNoCredentialsFound)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Strategy) PopulateLoginMethodIdentifierFirstIdentification(r *http.Request, sr *login.Flow) error {
|
||||
return nil
|
||||
}
|
||||
45
selfservice/strategy/password/login_autocomplete_test.go
Normal file
45
selfservice/strategy/password/login_autocomplete_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password_test
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"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/ui/node"
|
||||
"github.com/ory/x/configx"
|
||||
)
|
||||
|
||||
func TestLoginIdentifierAutocomplete(t *testing.T) {
|
||||
t.Skip() // TODO: https://github.com/ory-corp/cloud/issues/12416
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t,
|
||||
configx.WithValue(config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePassword)+".enabled", true),
|
||||
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://stub/login.schema.json")),
|
||||
)
|
||||
|
||||
s, err := reg.AllLoginStrategies().Strategy(identity.CredentialsTypePassword)
|
||||
require.NoError(t, err)
|
||||
fh, ok := s.(login.AAL1FormHydrator)
|
||||
require.True(t, ok)
|
||||
|
||||
r := httptest.NewRequest("GET", "/self-service/login/browser", nil).WithContext(t.Context())
|
||||
f, err := login.NewFlow(reg, r, flow.TypeBrowser)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, fh.PopulateLoginMethodFirstFactor(r, f))
|
||||
|
||||
identifier := f.UI.Nodes.Find("identifier")
|
||||
require.NotNil(t, identifier, "identifier node must exist")
|
||||
attr, ok := identifier.Attributes.(*node.InputAttributes)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, node.InputAttributeAutocompleteUsername, attr.Autocomplete)
|
||||
}
|
||||
59
selfservice/strategy/password/login_race_test.go
Normal file
59
selfservice/strategy/password/login_race_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/hash"
|
||||
"github.com/ory/kratos/pkg"
|
||||
)
|
||||
|
||||
// TestLoginConfigMutationIsolation guards against Race B: a config-mutating
|
||||
// subtest must not share its *configx.Provider with requests that read config
|
||||
// concurrently. The reader path (Bcrypt.Generate -> Config().HasherBcrypt ->
|
||||
// IsInsecureDevMode -> Provider.Bool) and the writer path (Config.MustSet ->
|
||||
// Provider.Set -> replaceKoanf, swapping p.Koanf) operate on the same
|
||||
// *configx.Provider when a registry is shared, which the race detector flags.
|
||||
//
|
||||
// The fix is that a config-mutating subtest owns its own registry+config, so
|
||||
// reads on one registry never race writes on another. This test verifies that
|
||||
// the per-registry isolation pattern is race-free: a reader keeps hashing on
|
||||
// one registry while a writer mutates config on its own, separate registry.
|
||||
func TestLoginConfigMutationIsolation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Reader owns its registry, mirroring an in-flight request that hashes a
|
||||
// password and reads IsInsecureDevMode on every call.
|
||||
_, readerReg := pkg.NewFastRegistryWithMocks(t)
|
||||
hasher := hash.NewHasherBcrypt(readerReg)
|
||||
|
||||
// Writer owns a separate registry+config, mirroring a config-mutating
|
||||
// subtest that calls MustSet (and its cleanup), as required by
|
||||
// .claude/rules/go.md: "subtests that need parallelization must own their
|
||||
// resources".
|
||||
writerConf, _ := pkg.NewFastRegistryWithMocks(t)
|
||||
|
||||
var eg errgroup.Group
|
||||
eg.Go(func() error {
|
||||
for range 200 {
|
||||
if _, err := hasher.Generate(t.Context(), []byte("a-long-enough-password")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
eg.Go(func() error {
|
||||
for range 200 {
|
||||
writerConf.MustSet(t.Context(), config.ViperKeyUseLegacyRequireVerifiedLoginError, true)
|
||||
writerConf.MustSet(t.Context(), config.ViperKeyUseLegacyRequireVerifiedLoginError, false)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, eg.Wait())
|
||||
}
|
||||
1515
selfservice/strategy/password/login_test.go
Normal file
1515
selfservice/strategy/password/login_test.go
Normal file
File diff suppressed because it is too large
Load Diff
19
selfservice/strategy/password/nodes.go
Normal file
19
selfservice/strategy/password/nodes.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password
|
||||
|
||||
import (
|
||||
"github.com/ory/kratos/text"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
)
|
||||
|
||||
func NewPasswordNode(name string, autocomplete node.UiNodeInputAttributeAutocomplete) *node.Node {
|
||||
return node.NewInputField(name, nil, node.PasswordGroup,
|
||||
node.InputAttributeTypePassword,
|
||||
node.WithRequiredInputAttribute,
|
||||
node.WithInputAttributes(func(a *node.InputAttributes) {
|
||||
a.Autocomplete = autocomplete
|
||||
})).
|
||||
WithMetaLabel(text.NewInfoNodeInputPassword())
|
||||
}
|
||||
224
selfservice/strategy/password/op_helpers_test.go
Normal file
224
selfservice/strategy/password/op_helpers_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/phayes/freeport"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/network"
|
||||
|
||||
dockertest "github.com/ory/dockertest/v4"
|
||||
hydraclientgo "github.com/ory/hydra-client-go/v2"
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
"github.com/ory/x/urlx"
|
||||
)
|
||||
|
||||
type clientAppConfig struct {
|
||||
client *oauth2.Config
|
||||
expectToken bool
|
||||
state *clientAppState
|
||||
}
|
||||
|
||||
type clientAppState struct {
|
||||
visits int64
|
||||
tokens int64
|
||||
}
|
||||
|
||||
type callTrace string
|
||||
|
||||
const (
|
||||
RegistrationUI callTrace = "registration-ui"
|
||||
RegistrationWithOAuth2LoginChallenge callTrace = "registration-with-oauth2-login-challenge"
|
||||
RegistrationWithFlowID callTrace = "registration-with-flow-id"
|
||||
LoginUI callTrace = "login-ui"
|
||||
LoginWithOAuth2LoginChallenge callTrace = "login-with-oauth2-login-challenge"
|
||||
LoginWithFlowID callTrace = "login-with-flow-id"
|
||||
Consent callTrace = "consent"
|
||||
ConsentWithChallenge callTrace = "consent-with-challenge"
|
||||
ConsentAccept callTrace = "consent-accept"
|
||||
ConsentSkip callTrace = "consent-skip"
|
||||
ConsentClientSkip callTrace = "consent-client-skip"
|
||||
CodeExchange callTrace = "code-exchange"
|
||||
CodeExchangeWithToken callTrace = "code-exchange-with-token"
|
||||
)
|
||||
|
||||
type testContextKey string
|
||||
|
||||
const (
|
||||
TestUIConfig testContextKey = "test-ui-config"
|
||||
TestOAuthClientState testContextKey = "test-oauth-client-state"
|
||||
)
|
||||
|
||||
type testConfig struct {
|
||||
identifier string
|
||||
password string
|
||||
browserClient *http.Client
|
||||
kratosPublicTS *httptest.Server
|
||||
clientAppTS *httptest.Server
|
||||
hydraAdminClient hydraclientgo.OAuth2API
|
||||
consentRemember bool
|
||||
requestedScope []string
|
||||
callTrace *[]callTrace
|
||||
}
|
||||
|
||||
func createHydraOAuth2ApiClient(url string) hydraclientgo.OAuth2API {
|
||||
configuration := hydraclientgo.NewConfiguration()
|
||||
configuration.Host = urlx.ParseOrPanic(url).Host
|
||||
configuration.Servers = hydraclientgo.ServerConfigurations{{URL: url}}
|
||||
|
||||
return hydraclientgo.NewAPIClient(configuration).OAuth2API
|
||||
}
|
||||
|
||||
func createOAuth2Client(t *testing.T, ctx context.Context, hydraAdmin hydraclientgo.OAuth2API, redirectURIs []string, scope string, skipConsent bool) string {
|
||||
t.Helper()
|
||||
|
||||
clientName := "kratos-hydra-integration-test-client-1"
|
||||
tokenEndpointAuthMethod := "client_secret_post"
|
||||
clientSecret := "client-secret"
|
||||
|
||||
c, r, err := hydraAdmin.CreateOAuth2Client(ctx).OAuth2Client(
|
||||
hydraclientgo.OAuth2Client{
|
||||
ClientName: &clientName,
|
||||
RedirectUris: redirectURIs,
|
||||
Scope: &scope,
|
||||
TokenEndpointAuthMethod: &tokenEndpointAuthMethod,
|
||||
ClientSecret: &clientSecret,
|
||||
SkipConsent: &skipConsent,
|
||||
},
|
||||
).Execute()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, r.StatusCode, http.StatusCreated)
|
||||
return *c.ClientId
|
||||
}
|
||||
|
||||
func makeAuthCodeURL(t *testing.T, c *oauth2.Config, requestedClaims string, isForced bool) string {
|
||||
t.Helper()
|
||||
|
||||
var options []oauth2.AuthCodeOption
|
||||
|
||||
if isForced {
|
||||
options = append(options, oauth2.SetAuthURLParam("prompt", "login"))
|
||||
}
|
||||
if requestedClaims != "" {
|
||||
options = append(options, oauth2.SetAuthURLParam("claims", requestedClaims))
|
||||
}
|
||||
|
||||
state := fmt.Sprintf("%x", uuid.Must(uuid.NewV4()))
|
||||
return c.AuthCodeURL(state, options...)
|
||||
}
|
||||
|
||||
func newHydra(t *testing.T, loginUI string, consentUI string) (hydraAdmin string, hydraPublic string) {
|
||||
pool := dockertest.NewPoolT(t, "")
|
||||
|
||||
// The public host port must be known before the container starts because it
|
||||
// is part of URLS_SELF_ISSUER. freeport releases the port before Docker binds
|
||||
// it, so another process can grab it in between — retry on that race.
|
||||
var hydraResource dockertest.ClosableResource
|
||||
ctx := t.Context()
|
||||
require.EventuallyWithT(t, func(t *assert.CollectT) {
|
||||
publicPort, err := freeport.GetFreePort()
|
||||
require.NoError(t, err)
|
||||
|
||||
hydraResource, err = pool.Run(ctx, "oryd/hydra",
|
||||
// Keep tag in sync with the version in ci.yaml
|
||||
dockertest.WithTag("v2.2.0"),
|
||||
dockertest.WithoutReuse(),
|
||||
dockertest.WithEnv([]string{
|
||||
"DSN=memory",
|
||||
fmt.Sprintf("URLS_SELF_ISSUER=http://127.0.0.1:%d/", publicPort),
|
||||
"URLS_LOGIN=" + loginUI,
|
||||
"URLS_CONSENT=" + consentUI,
|
||||
"LOG_LEAK_SENSITIVE_VALUES=true",
|
||||
"SECRETS_SYSTEM=someverylongsecretthatis32byteslong",
|
||||
}),
|
||||
dockertest.WithCmd([]string{"serve", "all", "--dev"}),
|
||||
dockertest.WithContainerConfig(func(cc *container.Config) {
|
||||
cc.ExposedPorts = network.PortSet{
|
||||
network.MustParsePort("4444/tcp"): struct{}{},
|
||||
network.MustParsePort("4445/tcp"): struct{}{},
|
||||
}
|
||||
}),
|
||||
dockertest.WithHostConfig(func(hc *container.HostConfig) {
|
||||
hc.PortBindings = network.PortMap{
|
||||
network.MustParsePort("4444/tcp"): {{HostPort: strconv.Itoa(publicPort)}},
|
||||
network.MustParsePort("4445/tcp"): {{HostPort: ""}},
|
||||
}
|
||||
}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
}, 2*time.Minute, 100*time.Millisecond)
|
||||
hydraResource.Cleanup(t)
|
||||
require.NotEmpty(t, hydraResource.GetPort("4444/tcp"), "%+v", hydraResource.Container().NetworkSettings.Ports)
|
||||
require.NotEmpty(t, hydraResource.GetPort("4445/tcp"), "%+v", hydraResource.Container)
|
||||
|
||||
hydraPublic = "http://127.0.0.1:" + hydraResource.GetPort("4444/tcp")
|
||||
hydraAdmin = "http://127.0.0.1:" + hydraResource.GetPort("4445/tcp")
|
||||
|
||||
go func() {
|
||||
stdout, stderr, err := hydraResource.Logs(context.Background())
|
||||
if err == nil {
|
||||
if stdout != "" {
|
||||
t.Logf("[hydra stdout]:\n%s\n", stdout)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Logf("[hydra stderr]:\n%s\n", stderr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
client := testhelpers.NewTestClient(t)
|
||||
require.EventuallyWithT(t, func(t *assert.CollectT) {
|
||||
res, err := client.Get(hydraPublic + "/health/ready")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, res.StatusCode)
|
||||
|
||||
res, err = client.Get(hydraAdmin + "/health/ready")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, res.StatusCode)
|
||||
}, 30*time.Second, time.Second)
|
||||
|
||||
t.Logf("Ory Hydra running at: %s %s", hydraPublic, hydraAdmin)
|
||||
|
||||
return hydraAdmin, hydraPublic
|
||||
}
|
||||
|
||||
type TestLogWriter struct {
|
||||
streamName string
|
||||
*testing.T
|
||||
}
|
||||
|
||||
func (t TestLogWriter) Write(p []byte) (int, error) {
|
||||
t.Logf("[%d bytes @ %s]:\n\n%s\n", len(p), t.streamName, string(p))
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func doOAuthFlow(t *testing.T, ctx context.Context, oauthClient *oauth2.Config, browserClient *http.Client) {
|
||||
t.Helper()
|
||||
|
||||
authCodeURL := makeAuthCodeURL(t, oauthClient, "", false)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, authCodeURL, nil)
|
||||
require.NoError(t, err)
|
||||
res, err := browserClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, res.Body.Close())
|
||||
require.Equal(t, "", string(body))
|
||||
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||
}
|
||||
850
selfservice/strategy/password/op_login_test.go
Normal file
850
selfservice/strategy/password/op_login_test.go
Normal file
@@ -0,0 +1,850 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/urfave/negroni"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
hydraclientgo "github.com/ory/hydra-client-go/v2"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/hydra"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
"github.com/ory/kratos/selfservice/flow/login"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
"github.com/ory/x/configx"
|
||||
"github.com/ory/x/httprouterx"
|
||||
)
|
||||
|
||||
func TestOAuth2Provider(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t,
|
||||
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypePassword, true)),
|
||||
)
|
||||
|
||||
var testRequireLogin atomic.Bool
|
||||
testRequireLogin.Store(true)
|
||||
|
||||
router := httprouterx.NewRouterPublic()
|
||||
kratosPublicTS, _ := testhelpers.NewKratosServerWithRouters(t, reg, router, httprouterx.NewRouterAdminWithPrefix())
|
||||
errTS := testhelpers.NewErrorTestServer(t, reg)
|
||||
redirTS := testhelpers.NewRedirSessionEchoTS(t, reg)
|
||||
|
||||
router.GET("/login-ts", func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Log("[loginTS] navigated to the login ui")
|
||||
c := r.Context().Value(TestUIConfig).(*testConfig)
|
||||
*c.callTrace = append(*c.callTrace, LoginUI)
|
||||
|
||||
q := r.URL.Query()
|
||||
hlc := r.URL.Query().Get("login_challenge")
|
||||
if hlc != "" {
|
||||
*c.callTrace = append(*c.callTrace, LoginWithOAuth2LoginChallenge)
|
||||
|
||||
loginUrl, err := url.Parse(c.kratosPublicTS.URL + login.RouteInitBrowserFlow)
|
||||
require.NoError(t, err)
|
||||
|
||||
q := loginUrl.Query()
|
||||
q.Set("login_challenge", hlc)
|
||||
loginUrl.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", loginUrl.String(), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := c.browserClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
|
||||
// if the registration page redirects us to the login page
|
||||
// we will sign in which means we might have a session
|
||||
var oryCookie *http.Cookie
|
||||
currentURL, err := url.Parse(kratosPublicTS.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, c := range c.browserClient.Jar.Cookies(currentURL) {
|
||||
if c.Name == config.DefaultSessionCookieName {
|
||||
oryCookie = c
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// in some cases the initialize login flow already navigated to the consent page
|
||||
// in which case no flow exists, but a session cookie does
|
||||
if oryCookie != nil && !resp.Request.URL.Query().Has("flow") {
|
||||
t.Logf("[loginTS] found a session cookie: %s", oryCookie.String())
|
||||
return
|
||||
}
|
||||
|
||||
flowID := resp.Request.URL.Query().Get("flow")
|
||||
lf := testhelpers.GetLoginFlow(t, c.browserClient, c.kratosPublicTS, flowID)
|
||||
require.NotNil(t, lf)
|
||||
|
||||
values := url.Values{"method": {"password"}, "identifier": {c.identifier}, "password": {c.password}, "csrf_token": {nosurfx.FakeCSRFToken}}.Encode()
|
||||
_, res := testhelpers.LoginMakeRequest(t, false, false, lf, c.browserClient, values)
|
||||
assert.EqualValues(t, http.StatusOK, res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
if q.Has("flow") {
|
||||
*c.callTrace = append(*c.callTrace, LoginWithFlowID)
|
||||
t.Log("[loginTS] login flow is ignored here since it will be handled by the code above, we just need to return")
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
router.GET("/consent", func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Log("[consentTS] navigated to the consent ui")
|
||||
c := r.Context().Value(TestUIConfig).(*testConfig)
|
||||
*c.callTrace = append(*c.callTrace, Consent)
|
||||
|
||||
q := r.URL.Query()
|
||||
consentChallenge := q.Get("consent_challenge")
|
||||
assert.NotEmpty(t, consentChallenge)
|
||||
|
||||
if consentChallenge != "" {
|
||||
*c.callTrace = append(*c.callTrace, ConsentWithChallenge)
|
||||
}
|
||||
|
||||
cr, resp, err := c.hydraAdminClient.GetOAuth2ConsentRequest(ctx).ConsentChallenge(q.Get("consent_challenge")).Execute()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.ElementsMatch(t, cr.RequestedScope, c.requestedScope)
|
||||
|
||||
if cr.GetSkip() {
|
||||
*c.callTrace = append(*c.callTrace, ConsentSkip)
|
||||
}
|
||||
|
||||
if cr.Client.GetSkipConsent() {
|
||||
*c.callTrace = append(*c.callTrace, ConsentClientSkip)
|
||||
}
|
||||
|
||||
completedAcceptRequest, resp, err := c.hydraAdminClient.AcceptOAuth2ConsentRequest(r.Context()).AcceptOAuth2ConsentRequest(hydraclientgo.AcceptOAuth2ConsentRequest{
|
||||
Remember: &c.consentRemember,
|
||||
GrantScope: c.requestedScope,
|
||||
}).ConsentChallenge(q.Get("consent_challenge")).Execute()
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
require.NotNil(t, completedAcceptRequest)
|
||||
*c.callTrace = append(*c.callTrace, ConsentAccept)
|
||||
|
||||
t.Logf("[consentTS] navigating to %s", completedAcceptRequest.RedirectTo)
|
||||
resp, err = c.browserClient.Get(completedAcceptRequest.RedirectTo)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c.clientAppTS.URL, fmt.Sprintf("%s://%s", resp.Request.URL.Scheme, resp.Request.URL.Host))
|
||||
})
|
||||
|
||||
kratosUIMiddleware := negroni.New()
|
||||
kratosUIMiddleware.UseFunc(func(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
||||
// add the context from the global context to each request
|
||||
next(rw, r.WithContext(ctx))
|
||||
})
|
||||
kratosUIMiddleware.UseHandler(router)
|
||||
|
||||
kratosUITS := testhelpers.NewHTTPTestServer(t, kratosUIMiddleware)
|
||||
|
||||
clientAppTSMiddleware := negroni.New()
|
||||
clientAppTSMiddleware.UseFunc(func(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
||||
// add the context from the global context to each request
|
||||
next(rw, r.WithContext(ctx))
|
||||
})
|
||||
clientAppTSMiddleware.UseHandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c := ctx.Value(TestOAuthClientState).(*clientAppConfig)
|
||||
kc := ctx.Value(TestUIConfig).(*testConfig)
|
||||
*kc.callTrace = append(*kc.callTrace, CodeExchange)
|
||||
|
||||
c.state.visits += 1
|
||||
t.Logf("[clientAppTS] handling a callback at client app %s", r.URL.String())
|
||||
if r.URL.Query().Has("code") {
|
||||
token, err := c.client.Exchange(r.Context(), r.URL.Query().Get("code"))
|
||||
require.NoError(t, err)
|
||||
|
||||
if token != nil && token.AccessToken != "" {
|
||||
t.Log("[clientAppTS] successfully exchanged code for token")
|
||||
*kc.callTrace = append(*kc.callTrace, CodeExchangeWithToken)
|
||||
c.state.tokens += 1
|
||||
} else {
|
||||
t.Log("[clientAppTS] did not receive a token")
|
||||
}
|
||||
} else {
|
||||
t.Error("[clientAppTS] code query parameter is missing")
|
||||
}
|
||||
})
|
||||
// A new OAuth client which will also function as the callback for the code exchange
|
||||
clientAppTS := testhelpers.NewHTTPTestServer(t, clientAppTSMiddleware)
|
||||
|
||||
conf.MustSet(ctx, config.ViperKeySecretsDefault, []string{"not-a-secure-session-key"})
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceErrorUI, errTS.URL+"/error-ts")
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceLoginUI, kratosUITS.URL+"/login-ts")
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, redirTS.URL+"/return-ts")
|
||||
conf.MustSet(ctx, config.ViperKeySessionPersistentCookie, true)
|
||||
|
||||
testhelpers.SetDefaultIdentitySchema(conf, "file://stub/login.schema.json")
|
||||
|
||||
hydraAdmin, hydraPublic := newHydra(t, kratosUITS.URL+"/login-ts", kratosUITS.URL+"/consent")
|
||||
conf.MustSet(ctx, config.ViperKeyOAuth2ProviderURL, hydraAdmin)
|
||||
|
||||
hydraAdminClient := createHydraOAuth2ApiClient(hydraAdmin)
|
||||
|
||||
loginToAccount := func(t *testing.T, browserClient *http.Client, identifier, pwd string) {
|
||||
f := testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, kratosPublicTS, false, false, false, false)
|
||||
|
||||
values := url.Values{"method": {"password"}, "identifier": {identifier}, "password": {pwd}, "csrf_token": {nosurfx.FakeCSRFToken}}.Encode()
|
||||
|
||||
body, res := testhelpers.LoginMakeRequest(t, false, false, f, browserClient, values)
|
||||
|
||||
assert.EqualValues(t, http.StatusOK, res.StatusCode)
|
||||
assert.Equal(t, identifier, gjson.Get(body, "identity.traits.subject").String(), "%s", body)
|
||||
}
|
||||
|
||||
t.Run("should prompt the user for login and consent", func(t *testing.T) {
|
||||
// This is the default case, where the user is prompted for login and consent.
|
||||
|
||||
conf.MustSet(ctx, config.ViperKeySessionPersistentCookie, false)
|
||||
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(ctx, config.ViperKeySessionPersistentCookie, true)
|
||||
})
|
||||
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
|
||||
|
||||
identifier, pwd := x.NewUUID().String(), "password"
|
||||
createIdentity(ctx, reg, t, identifier, pwd)
|
||||
|
||||
scopes := []string{"profile", "email"}
|
||||
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), false)
|
||||
oauthClient := &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: "client-secret",
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: hydraPublic + "/oauth2/auth",
|
||||
TokenURL: hydraPublic + "/oauth2/token",
|
||||
AuthStyle: oauth2.AuthStyleInParams,
|
||||
},
|
||||
Scopes: scopes,
|
||||
RedirectURL: clientAppTS.URL,
|
||||
}
|
||||
|
||||
clientAS := clientAppState{
|
||||
visits: 0,
|
||||
tokens: 0,
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, TestOAuthClientState, &clientAppConfig{
|
||||
client: oauthClient,
|
||||
state: &clientAS,
|
||||
expectToken: true,
|
||||
})
|
||||
|
||||
ct := make([]callTrace, 0)
|
||||
|
||||
tc := &testConfig{
|
||||
browserClient: browserClient,
|
||||
kratosPublicTS: kratosPublicTS,
|
||||
hydraAdminClient: hydraAdminClient,
|
||||
clientAppTS: clientAppTS,
|
||||
callTrace: &ct,
|
||||
requestedScope: scopes,
|
||||
consentRemember: true,
|
||||
identifier: identifier,
|
||||
password: pwd,
|
||||
}
|
||||
ctx = context.WithValue(ctx, TestUIConfig, tc)
|
||||
|
||||
doOAuthFlow(t, ctx, oauthClient, browserClient)
|
||||
|
||||
assert.EqualValues(t, clientAppState{
|
||||
visits: 1,
|
||||
tokens: 1,
|
||||
}, clientAS)
|
||||
|
||||
expected := []callTrace{
|
||||
LoginUI,
|
||||
LoginWithOAuth2LoginChallenge,
|
||||
LoginUI,
|
||||
LoginWithFlowID,
|
||||
Consent,
|
||||
ConsentWithChallenge,
|
||||
ConsentAccept,
|
||||
CodeExchange,
|
||||
CodeExchangeWithToken,
|
||||
}
|
||||
require.ElementsMatch(t, expected, ct)
|
||||
})
|
||||
|
||||
t.Run("should prompt the user for login and consent again", func(t *testing.T) {
|
||||
// This test verifies that when Kratos is set
|
||||
// to SessionPersistentCookie=false, the user is not
|
||||
// remembered from the previous OAuth2 flow.
|
||||
// The user must then re-authenticate and re-consent.
|
||||
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
|
||||
|
||||
identifier, pwd := x.NewUUID().String(), "password"
|
||||
createIdentity(ctx, reg, t, identifier, pwd)
|
||||
|
||||
scopes := []string{"profile", "email"}
|
||||
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), false)
|
||||
|
||||
oauthClient := &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: "client-secret",
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: hydraPublic + "/oauth2/auth",
|
||||
TokenURL: hydraPublic + "/oauth2/token",
|
||||
AuthStyle: oauth2.AuthStyleInParams,
|
||||
},
|
||||
Scopes: scopes,
|
||||
RedirectURL: clientAppTS.URL,
|
||||
}
|
||||
|
||||
clientAS := clientAppState{
|
||||
visits: 0,
|
||||
tokens: 0,
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, TestOAuthClientState, &clientAppConfig{
|
||||
client: oauthClient,
|
||||
state: &clientAS,
|
||||
expectToken: true,
|
||||
})
|
||||
|
||||
ct := make([]callTrace, 0)
|
||||
|
||||
tc := &testConfig{
|
||||
password: pwd,
|
||||
identifier: identifier,
|
||||
hydraAdminClient: hydraAdminClient,
|
||||
browserClient: browserClient,
|
||||
kratosPublicTS: kratosPublicTS,
|
||||
clientAppTS: clientAppTS,
|
||||
callTrace: &ct,
|
||||
requestedScope: scopes,
|
||||
consentRemember: false,
|
||||
}
|
||||
ctx = context.WithValue(ctx, TestUIConfig, tc)
|
||||
|
||||
conf.MustSet(ctx, config.ViperKeySessionPersistentCookie, false)
|
||||
|
||||
doOAuthFlow(t, ctx, oauthClient, browserClient)
|
||||
|
||||
assert.EqualValues(t, clientAppState{
|
||||
visits: 1,
|
||||
tokens: 1,
|
||||
}, clientAS)
|
||||
|
||||
expected := []callTrace{
|
||||
LoginUI,
|
||||
LoginWithOAuth2LoginChallenge,
|
||||
LoginUI,
|
||||
LoginWithFlowID,
|
||||
Consent,
|
||||
ConsentWithChallenge,
|
||||
ConsentAccept,
|
||||
CodeExchange,
|
||||
CodeExchangeWithToken,
|
||||
}
|
||||
require.ElementsMatch(t, expected, ct)
|
||||
|
||||
// Reset the call trace
|
||||
ct = []callTrace{}
|
||||
|
||||
conf.MustSet(ctx, config.ViperKeySessionPersistentCookie, true)
|
||||
doOAuthFlow(t, ctx, oauthClient, browserClient)
|
||||
|
||||
assert.EqualValues(t, clientAppState{
|
||||
visits: 2,
|
||||
tokens: 2,
|
||||
}, clientAS)
|
||||
|
||||
expected = []callTrace{
|
||||
LoginUI,
|
||||
LoginWithOAuth2LoginChallenge,
|
||||
LoginUI,
|
||||
LoginWithFlowID,
|
||||
Consent,
|
||||
ConsentWithChallenge,
|
||||
ConsentAccept,
|
||||
CodeExchange,
|
||||
CodeExchangeWithToken,
|
||||
}
|
||||
require.ElementsMatch(t, expected, ct)
|
||||
})
|
||||
|
||||
t.Run("should prompt the user for consent, but not for login", func(t *testing.T) {
|
||||
// This test verifies that when Kratos is set
|
||||
// to SessionPersistentCookie=true, the user is
|
||||
// remembered from the previous OAuth2 flow.
|
||||
// The user must then only re-consent.
|
||||
conf.MustSet(ctx, config.ViperKeySessionPersistentCookie, true)
|
||||
|
||||
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
|
||||
|
||||
identifier, pwd := x.NewUUID().String(), "password"
|
||||
|
||||
createIdentity(ctx, reg, t, identifier, pwd)
|
||||
|
||||
scopes := []string{"profile", "email"}
|
||||
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), false)
|
||||
oauthClient := &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: "client-secret",
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: hydraPublic + "/oauth2/auth",
|
||||
TokenURL: hydraPublic + "/oauth2/token",
|
||||
AuthStyle: oauth2.AuthStyleInParams,
|
||||
},
|
||||
Scopes: scopes,
|
||||
RedirectURL: clientAppTS.URL,
|
||||
}
|
||||
|
||||
clientAS := clientAppState{
|
||||
visits: 0,
|
||||
tokens: 0,
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, TestOAuthClientState, &clientAppConfig{
|
||||
client: oauthClient,
|
||||
state: &clientAS,
|
||||
expectToken: true,
|
||||
})
|
||||
|
||||
ct := make([]callTrace, 0)
|
||||
|
||||
tc := &testConfig{
|
||||
hydraAdminClient: hydraAdminClient,
|
||||
browserClient: browserClient,
|
||||
kratosPublicTS: kratosPublicTS,
|
||||
clientAppTS: clientAppTS,
|
||||
callTrace: &ct,
|
||||
requestedScope: scopes,
|
||||
consentRemember: false,
|
||||
password: pwd,
|
||||
identifier: identifier,
|
||||
}
|
||||
ctx = context.WithValue(ctx, TestUIConfig, tc)
|
||||
|
||||
doOAuthFlow(t, ctx, oauthClient, browserClient)
|
||||
|
||||
assert.EqualValues(t, clientAppState{
|
||||
visits: 1,
|
||||
tokens: 1,
|
||||
}, clientAS)
|
||||
|
||||
expected := []callTrace{
|
||||
LoginUI,
|
||||
LoginWithOAuth2LoginChallenge,
|
||||
LoginUI,
|
||||
LoginWithFlowID,
|
||||
Consent,
|
||||
ConsentWithChallenge,
|
||||
ConsentAccept,
|
||||
CodeExchange,
|
||||
CodeExchangeWithToken,
|
||||
}
|
||||
require.ElementsMatch(t, expected, ct)
|
||||
|
||||
// reset the call trace
|
||||
ct = []callTrace{}
|
||||
|
||||
doOAuthFlow(t, ctx, oauthClient, browserClient)
|
||||
|
||||
assert.EqualValues(t, clientAppState{
|
||||
visits: 2,
|
||||
tokens: 2,
|
||||
}, clientAS)
|
||||
|
||||
expected = []callTrace{
|
||||
LoginUI,
|
||||
LoginWithOAuth2LoginChallenge,
|
||||
Consent,
|
||||
ConsentWithChallenge,
|
||||
ConsentAccept,
|
||||
CodeExchange,
|
||||
CodeExchangeWithToken,
|
||||
}
|
||||
|
||||
require.ElementsMatch(t, expected, ct)
|
||||
})
|
||||
|
||||
t.Run("should prompt login even with session with OAuth flow", func(t *testing.T) {
|
||||
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
|
||||
|
||||
identifier, pwd := x.NewUUID().String(), "password"
|
||||
createIdentity(ctx, reg, t, identifier, pwd)
|
||||
|
||||
scopes := []string{"profile", "email"}
|
||||
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), false)
|
||||
oauthClient := &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: "client-secret",
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: hydraPublic + "/oauth2/auth",
|
||||
TokenURL: hydraPublic + "/oauth2/token",
|
||||
AuthStyle: oauth2.AuthStyleInParams,
|
||||
},
|
||||
Scopes: scopes,
|
||||
RedirectURL: clientAppTS.URL,
|
||||
}
|
||||
|
||||
ct := make([]callTrace, 0)
|
||||
|
||||
tc := &testConfig{
|
||||
browserClient: browserClient,
|
||||
kratosPublicTS: kratosPublicTS,
|
||||
clientAppTS: clientAppTS,
|
||||
callTrace: &ct,
|
||||
requestedScope: scopes,
|
||||
hydraAdminClient: hydraAdminClient,
|
||||
identifier: identifier,
|
||||
password: pwd,
|
||||
consentRemember: false,
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, TestUIConfig, tc)
|
||||
|
||||
clientAS := clientAppState{
|
||||
visits: 0,
|
||||
tokens: 0,
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, TestOAuthClientState, &clientAppConfig{
|
||||
client: oauthClient,
|
||||
state: &clientAS,
|
||||
expectToken: true,
|
||||
})
|
||||
|
||||
loginToAccount(t, browserClient, identifier, pwd)
|
||||
|
||||
doOAuthFlow(t, ctx, oauthClient, browserClient)
|
||||
|
||||
assert.EqualValues(t, clientAppState{
|
||||
visits: 1,
|
||||
tokens: 1,
|
||||
}, clientAS)
|
||||
|
||||
require.ElementsMatch(t, []callTrace{
|
||||
LoginUI,
|
||||
LoginWithFlowID,
|
||||
LoginUI,
|
||||
LoginWithOAuth2LoginChallenge,
|
||||
LoginUI,
|
||||
LoginWithFlowID,
|
||||
Consent,
|
||||
ConsentWithChallenge,
|
||||
ConsentAccept,
|
||||
CodeExchange,
|
||||
CodeExchangeWithToken,
|
||||
}, ct)
|
||||
})
|
||||
|
||||
t.Run("first party clients can skip consent", func(t *testing.T) {
|
||||
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
|
||||
|
||||
identifier, pwd := x.NewUUID().String(), "password"
|
||||
createIdentity(ctx, reg, t, identifier, pwd)
|
||||
|
||||
clientSkipConsent := true
|
||||
scopes := []string{"profile", "email"}
|
||||
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), clientSkipConsent)
|
||||
oauthClient := &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: "client-secret",
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: hydraPublic + "/oauth2/auth",
|
||||
TokenURL: hydraPublic + "/oauth2/token",
|
||||
AuthStyle: oauth2.AuthStyleInParams,
|
||||
},
|
||||
Scopes: scopes,
|
||||
RedirectURL: clientAppTS.URL,
|
||||
}
|
||||
|
||||
ct := make([]callTrace, 0)
|
||||
|
||||
tc := &testConfig{
|
||||
browserClient: browserClient,
|
||||
kratosPublicTS: kratosPublicTS,
|
||||
clientAppTS: clientAppTS,
|
||||
callTrace: &ct,
|
||||
requestedScope: scopes,
|
||||
hydraAdminClient: hydraAdminClient,
|
||||
identifier: identifier,
|
||||
password: pwd,
|
||||
consentRemember: false,
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, TestUIConfig, tc)
|
||||
|
||||
clientAS := clientAppState{
|
||||
visits: 0,
|
||||
tokens: 0,
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, TestOAuthClientState, &clientAppConfig{
|
||||
client: oauthClient,
|
||||
state: &clientAS,
|
||||
expectToken: true,
|
||||
})
|
||||
|
||||
doOAuthFlow(t, ctx, oauthClient, browserClient)
|
||||
|
||||
assert.EqualValues(t, clientAppState{
|
||||
visits: 1,
|
||||
tokens: 1,
|
||||
}, clientAS)
|
||||
|
||||
require.ElementsMatch(t, []callTrace{
|
||||
LoginUI,
|
||||
LoginWithOAuth2LoginChallenge,
|
||||
LoginUI,
|
||||
LoginWithFlowID,
|
||||
Consent,
|
||||
ConsentWithChallenge,
|
||||
ConsentClientSkip,
|
||||
ConsentAccept,
|
||||
CodeExchange,
|
||||
CodeExchangeWithToken,
|
||||
}, ct)
|
||||
})
|
||||
|
||||
t.Run("oauth flow with consent remember, skips consent", func(t *testing.T) {
|
||||
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
|
||||
|
||||
identifier, pwd := x.NewUUID().String(), "password"
|
||||
createIdentity(ctx, reg, t, identifier, pwd)
|
||||
|
||||
scopes := []string{"profile", "email"}
|
||||
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), false)
|
||||
oauthClient := &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: "client-secret",
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: hydraPublic + "/oauth2/auth",
|
||||
TokenURL: hydraPublic + "/oauth2/token",
|
||||
AuthStyle: oauth2.AuthStyleInParams,
|
||||
},
|
||||
Scopes: scopes,
|
||||
RedirectURL: clientAppTS.URL,
|
||||
}
|
||||
|
||||
ct := make([]callTrace, 0)
|
||||
|
||||
tc := &testConfig{
|
||||
browserClient: browserClient,
|
||||
kratosPublicTS: kratosPublicTS,
|
||||
clientAppTS: clientAppTS,
|
||||
callTrace: &ct,
|
||||
requestedScope: scopes,
|
||||
hydraAdminClient: hydraAdminClient,
|
||||
identifier: identifier,
|
||||
password: pwd,
|
||||
consentRemember: true,
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, TestUIConfig, tc)
|
||||
|
||||
clientAS := clientAppState{
|
||||
visits: 0,
|
||||
tokens: 0,
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, TestOAuthClientState, &clientAppConfig{
|
||||
client: oauthClient,
|
||||
state: &clientAS,
|
||||
expectToken: true,
|
||||
})
|
||||
|
||||
doOAuthFlow(t, ctx, oauthClient, browserClient)
|
||||
|
||||
assert.EqualValues(t, clientAppState{
|
||||
visits: 1,
|
||||
tokens: 1,
|
||||
}, clientAS)
|
||||
|
||||
require.ElementsMatch(t, []callTrace{
|
||||
LoginUI,
|
||||
LoginWithOAuth2LoginChallenge,
|
||||
LoginUI,
|
||||
LoginWithFlowID,
|
||||
Consent,
|
||||
ConsentWithChallenge,
|
||||
ConsentAccept,
|
||||
CodeExchange,
|
||||
CodeExchangeWithToken,
|
||||
}, ct)
|
||||
|
||||
// reset the call trace
|
||||
ct = []callTrace{}
|
||||
clientAS = clientAppState{
|
||||
visits: 0,
|
||||
tokens: 0,
|
||||
}
|
||||
doOAuthFlow(t, ctx, oauthClient, browserClient)
|
||||
|
||||
assert.EqualValues(t, clientAppState{
|
||||
visits: 1,
|
||||
tokens: 1,
|
||||
}, clientAS)
|
||||
|
||||
require.ElementsMatch(t, []callTrace{
|
||||
LoginUI,
|
||||
LoginWithOAuth2LoginChallenge,
|
||||
Consent,
|
||||
ConsentWithChallenge,
|
||||
ConsentSkip,
|
||||
ConsentAccept,
|
||||
CodeExchange,
|
||||
CodeExchangeWithToken,
|
||||
}, ct)
|
||||
})
|
||||
|
||||
t.Run("should fail when Hydra session subject doesn't match the subject authenticated by Kratos", func(t *testing.T) {
|
||||
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
|
||||
|
||||
identifier, pwd := x.NewUUID().String(), "password"
|
||||
createIdentity(ctx, reg, t, identifier, pwd)
|
||||
|
||||
scopes := []string{"profile", "email"}
|
||||
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), false)
|
||||
oauthClient := &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: "client-secret",
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: hydraPublic + "/oauth2/auth",
|
||||
TokenURL: hydraPublic + "/oauth2/token",
|
||||
AuthStyle: oauth2.AuthStyleInParams,
|
||||
},
|
||||
Scopes: scopes,
|
||||
RedirectURL: clientAppTS.URL,
|
||||
}
|
||||
|
||||
ct := make([]callTrace, 0)
|
||||
|
||||
tc := &testConfig{
|
||||
browserClient: browserClient,
|
||||
kratosPublicTS: kratosPublicTS,
|
||||
clientAppTS: clientAppTS,
|
||||
callTrace: &ct,
|
||||
requestedScope: scopes,
|
||||
hydraAdminClient: hydraAdminClient,
|
||||
identifier: identifier,
|
||||
password: pwd,
|
||||
consentRemember: false,
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, TestUIConfig, tc)
|
||||
|
||||
clientAS := clientAppState{
|
||||
visits: 0,
|
||||
tokens: 0,
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, TestOAuthClientState, &clientAppConfig{
|
||||
client: oauthClient,
|
||||
state: &clientAS,
|
||||
expectToken: false,
|
||||
})
|
||||
|
||||
doOAuthFlow(t, ctx, oauthClient, browserClient)
|
||||
|
||||
assert.EqualValues(t, clientAppState{
|
||||
visits: 1,
|
||||
tokens: 1,
|
||||
}, clientAS)
|
||||
|
||||
require.ElementsMatch(t, []callTrace{
|
||||
LoginUI,
|
||||
LoginWithFlowID,
|
||||
LoginUI,
|
||||
LoginWithOAuth2LoginChallenge,
|
||||
Consent,
|
||||
ConsentWithChallenge,
|
||||
ConsentAccept,
|
||||
CodeExchange,
|
||||
CodeExchangeWithToken,
|
||||
}, ct)
|
||||
|
||||
// reset the call trace
|
||||
ct = []callTrace{}
|
||||
clientAS = clientAppState{
|
||||
visits: 0,
|
||||
tokens: 0,
|
||||
}
|
||||
|
||||
reg.SetHydra(&AcceptWrongSubject{h: reg.Hydra().(*hydra.DefaultHydra)})
|
||||
|
||||
doOAuthFlow(t, ctx, oauthClient, browserClient)
|
||||
|
||||
// In Hydra v2.2.0-rc.3 this would have failed outright: Hydra returned an error
|
||||
// when AcceptLoginRequest arrived with a subject that did not match the subject
|
||||
// stored in the existing login session (skip=true flow).
|
||||
//
|
||||
// In Hydra v2.2.0 final the mismatch is handled gracefully instead of with an
|
||||
// error. When Hydra detects that the accepted subject differs from the session
|
||||
// subject (consent/handler.go, acceptOAuth2LoginRequest), it redirects the
|
||||
// browser back to the original authorization URL with prompt=login appended.
|
||||
// That restart produces a fresh flow with f.Subject="" (no prior session
|
||||
// context), so the mismatch guard (f.Subject != "" && payload.Subject !=
|
||||
// f.Subject) does not fire on the second attempt — and AcceptWrongSubject's
|
||||
// random UUID is accepted, eventually completing the flow.
|
||||
//
|
||||
// Security note: this is not exploitable in production. AcceptLoginRequest is a
|
||||
// trusted admin API reachable only by the login provider (Kratos), which always
|
||||
// sends the correctly authenticated identity ID. AcceptWrongSubject is a test
|
||||
// construct only. The change removes a hard early-failure signal for accidental
|
||||
// subject substitution but does not introduce a new attack surface.
|
||||
assert.EqualValues(t, clientAppState{
|
||||
visits: 1,
|
||||
tokens: 1,
|
||||
}, clientAS)
|
||||
|
||||
expected := []callTrace{
|
||||
LoginUI,
|
||||
LoginWithOAuth2LoginChallenge, // skip=true, wrong subject → prompt=login redirect
|
||||
LoginUI,
|
||||
LoginWithOAuth2LoginChallenge, // skip=false, f.Subject="" → guard bypassed, wrong subject accepted
|
||||
LoginUI,
|
||||
LoginWithFlowID,
|
||||
Consent,
|
||||
ConsentWithChallenge,
|
||||
ConsentAccept,
|
||||
CodeExchange,
|
||||
CodeExchangeWithToken,
|
||||
}
|
||||
require.ElementsMatch(t, expected, ct)
|
||||
})
|
||||
}
|
||||
|
||||
var _ hydra.Hydra = &AcceptWrongSubject{}
|
||||
|
||||
type AcceptWrongSubject struct {
|
||||
h *hydra.DefaultHydra
|
||||
}
|
||||
|
||||
func (h *AcceptWrongSubject) AcceptLoginRequest(ctx context.Context, params hydra.AcceptLoginRequestParams) (string, error) {
|
||||
params.IdentityID = uuid.Must(uuid.NewV4()).String()
|
||||
return h.h.AcceptLoginRequest(ctx, params)
|
||||
}
|
||||
|
||||
func (h *AcceptWrongSubject) GetLoginRequest(ctx context.Context, loginChallenge string) (*hydraclientgo.OAuth2LoginRequest, error) {
|
||||
return h.h.GetLoginRequest(ctx, loginChallenge)
|
||||
}
|
||||
905
selfservice/strategy/password/op_registration_test.go
Normal file
905
selfservice/strategy/password/op_registration_test.go
Normal file
@@ -0,0 +1,905 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/urfave/negroni"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
hydraclientgo "github.com/ory/hydra-client-go/v2"
|
||||
"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/registration"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/x/configx"
|
||||
)
|
||||
|
||||
func TestOAuth2ProviderRegistration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeySelfServiceRegistrationEnableLegacyOneStep, true))
|
||||
|
||||
kratosPublicTS, _ := testhelpers.NewKratosServer(t, reg)
|
||||
errTS := testhelpers.NewErrorTestServer(t, reg)
|
||||
redirTS := testhelpers.NewRedirSessionEchoTS(t, reg)
|
||||
|
||||
var hydraAdminClient hydraclientgo.OAuth2API
|
||||
|
||||
router := http.NewServeMux()
|
||||
|
||||
type contextKey string
|
||||
const (
|
||||
TestUIConfig contextKey = "test-ui-config"
|
||||
TestOAuthClientState contextKey = "test-oauth-client-state"
|
||||
)
|
||||
|
||||
router.HandleFunc("GET /login-ts", func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Log("[loginTS] navigated to the login ui")
|
||||
c := r.Context().Value(TestUIConfig).(*testConfig)
|
||||
*c.callTrace = append(*c.callTrace, LoginUI)
|
||||
|
||||
q := r.URL.Query()
|
||||
hlc := r.URL.Query().Get("login_challenge")
|
||||
|
||||
if hlc != "" {
|
||||
*c.callTrace = append(*c.callTrace, LoginWithOAuth2LoginChallenge)
|
||||
return
|
||||
}
|
||||
|
||||
if q.Has("flow") {
|
||||
*c.callTrace = append(*c.callTrace, LoginWithFlowID)
|
||||
lf := testhelpers.GetLoginFlow(t, c.browserClient, c.kratosPublicTS, q.Get("flow"))
|
||||
require.NotNil(t, lf)
|
||||
values := testhelpers.SDKFormFieldsToURLValues(lf.Ui.Nodes)
|
||||
values.Set("password", c.password)
|
||||
|
||||
_, _ = testhelpers.LoginMakeRequest(t, false, false, lf, c.browserClient, values.Encode())
|
||||
t.Log("[loginTS] login flow is ignored here since it will be handled by the code above, we just need to return")
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
router.HandleFunc("GET /registration-ts", func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Log("[registrationTS] navigated to the registration ui")
|
||||
c := r.Context().Value(TestUIConfig).(*testConfig)
|
||||
*c.callTrace = append(*c.callTrace, RegistrationUI)
|
||||
|
||||
q := r.URL.Query()
|
||||
hlc := q.Get("login_challenge")
|
||||
|
||||
if hlc != "" {
|
||||
*c.callTrace = append(*c.callTrace, RegistrationWithOAuth2LoginChallenge)
|
||||
t.Log("[registrationTS] initializing a new OpenID Provider flow through the registration endpoint")
|
||||
registrationUrl, err := url.Parse(c.kratosPublicTS.URL + registration.RouteInitBrowserFlow)
|
||||
require.NoError(t, err)
|
||||
|
||||
q := registrationUrl.Query()
|
||||
q.Set("login_challenge", hlc)
|
||||
registrationUrl.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", registrationUrl.String(), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := c.browserClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, http.StatusOK, resp.StatusCode)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
|
||||
// if the registration page redirects us to the login page
|
||||
// we will sign in which means we might have a session
|
||||
var oryCookie *http.Cookie
|
||||
currentURL, err := url.Parse(kratosPublicTS.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, c := range c.browserClient.Jar.Cookies(currentURL) {
|
||||
if c.Name == config.DefaultSessionCookieName {
|
||||
oryCookie = c
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if oryCookie != nil {
|
||||
t.Log("[registrationTS] we expect to have been at the login screen and got an active flow. This means we have a session now")
|
||||
return
|
||||
}
|
||||
|
||||
flowID := resp.Request.URL.Query().Get("flow")
|
||||
assert.NotEmpty(t, flowID)
|
||||
|
||||
f := testhelpers.GetRegistrationFlow(t, c.browserClient, c.kratosPublicTS, flowID)
|
||||
require.NotNil(t, f)
|
||||
|
||||
// continue the registration flow here
|
||||
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
||||
values.Set("traits.foobar", c.identifier)
|
||||
values.Set("traits.username", c.identifier)
|
||||
values.Set("password", c.password)
|
||||
|
||||
_, res := testhelpers.RegistrationMakeRequest(t, false, false, f, c.browserClient, values.Encode())
|
||||
assert.EqualValues(t, http.StatusOK, res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
if q.Has("flow") {
|
||||
*c.callTrace = append(*c.callTrace, RegistrationWithFlowID)
|
||||
t.Log("[registrationTS] registration flow is ignored here since it will be handled by the code above, we just need to return")
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
router.HandleFunc("GET /consent", func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Log("[consentTS] navigated to the consent ui")
|
||||
c := r.Context().Value(TestUIConfig).(*testConfig)
|
||||
*c.callTrace = append(*c.callTrace, Consent)
|
||||
|
||||
q := r.URL.Query()
|
||||
consentChallenge := q.Get("consent_challenge")
|
||||
assert.NotEmpty(t, consentChallenge)
|
||||
|
||||
if consentChallenge != "" {
|
||||
*c.callTrace = append(*c.callTrace, ConsentWithChallenge)
|
||||
}
|
||||
|
||||
cr, resp, err := hydraAdminClient.GetOAuth2ConsentRequest(ctx).ConsentChallenge(q.Get("consent_challenge")).Execute()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.ElementsMatch(t, cr.RequestedScope, c.requestedScope)
|
||||
|
||||
if cr.GetSkip() {
|
||||
*c.callTrace = append(*c.callTrace, ConsentSkip)
|
||||
}
|
||||
|
||||
if cr.Client.GetSkipConsent() {
|
||||
*c.callTrace = append(*c.callTrace, ConsentClientSkip)
|
||||
}
|
||||
|
||||
completedAcceptRequest, resp, err := hydraAdminClient.AcceptOAuth2ConsentRequest(r.Context()).AcceptOAuth2ConsentRequest(hydraclientgo.AcceptOAuth2ConsentRequest{
|
||||
Remember: &c.consentRemember,
|
||||
GrantScope: c.requestedScope,
|
||||
}).ConsentChallenge(q.Get("consent_challenge")).Execute()
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
require.NotNil(t, completedAcceptRequest)
|
||||
*c.callTrace = append(*c.callTrace, ConsentAccept)
|
||||
|
||||
t.Logf("[consentTS] navigating to %s", completedAcceptRequest.RedirectTo)
|
||||
resp, err = c.browserClient.Get(completedAcceptRequest.RedirectTo)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c.clientAppTS.URL, fmt.Sprintf("%s://%s", resp.Request.URL.Scheme, resp.Request.URL.Host))
|
||||
})
|
||||
|
||||
kratosUIMiddleware := negroni.New()
|
||||
kratosUIMiddleware.UseFunc(func(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
||||
// add the context from the global context to each request
|
||||
next(rw, r.WithContext(ctx))
|
||||
})
|
||||
kratosUIMiddleware.UseHandler(router)
|
||||
|
||||
kratosUITS := testhelpers.NewHTTPTestServer(t, kratosUIMiddleware)
|
||||
|
||||
clientAppTSMiddleware := negroni.New()
|
||||
clientAppTSMiddleware.UseFunc(func(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
||||
// add the context from the global context to each request
|
||||
next(rw, r.WithContext(ctx))
|
||||
})
|
||||
clientAppTSMiddleware.UseHandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c := ctx.Value(TestOAuthClientState).(*clientAppConfig)
|
||||
kc := ctx.Value(TestUIConfig).(*testConfig)
|
||||
*kc.callTrace = append(*kc.callTrace, CodeExchange)
|
||||
|
||||
c.state.visits += 1
|
||||
t.Logf("[clientAppTS] handling a callback at client app %s", r.URL.String())
|
||||
if r.URL.Query().Has("code") {
|
||||
token, err := c.client.Exchange(r.Context(), r.URL.Query().Get("code"))
|
||||
require.NoError(t, err)
|
||||
|
||||
if token != nil && token.AccessToken != "" {
|
||||
t.Log("[clientAppTS] successfully exchanged code for token")
|
||||
*kc.callTrace = append(*kc.callTrace, CodeExchangeWithToken)
|
||||
c.state.tokens += 1
|
||||
} else {
|
||||
t.Log("[clientAppTS] did not receive a token")
|
||||
}
|
||||
} else {
|
||||
t.Error("[clientAppTS] code query parameter is missing")
|
||||
}
|
||||
})
|
||||
// A new OAuth client which will also function as the callback for the code exchange
|
||||
clientAppTS := testhelpers.NewHTTPTestServer(t, clientAppTSMiddleware)
|
||||
|
||||
// we want to test if the registration ui is used if the flow contains an oauth2 login challenge
|
||||
// so we will have Hydra redirect to the base path of the test kratos ui server which
|
||||
// will then initiate the registration flow
|
||||
hydraAdmin, hydraPublic := newHydra(t, kratosUITS.URL+"/registration-ts", kratosUITS.URL+"/consent")
|
||||
|
||||
hydraAdminClient = createHydraOAuth2ApiClient(hydraAdmin)
|
||||
|
||||
conf.MustSet(ctx, config.ViperKeyOAuth2ProviderURL, hydraAdmin+"/")
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceErrorUI, errTS.URL+"/error-ts")
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceLoginUI, kratosUITS.URL+"/login-ts")
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationUI, kratosUITS.URL+"/registration-ts")
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, redirTS.URL+"/return-ts")
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationAfter+"."+config.DefaultBrowserReturnURL, redirTS.URL+"/registration-return-ts")
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePassword), map[string]interface{}{"enabled": true})
|
||||
conf.MustSet(ctx, config.ViperKeySecretsDefault, []string{"not-a-secure-session-key"})
|
||||
conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), []config.SelfServiceHook{{Name: "session"}})
|
||||
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json")
|
||||
|
||||
doOAuthFlow := func(t *testing.T, ctx context.Context, oauthClient *oauth2.Config, browserClient *http.Client) {
|
||||
t.Helper()
|
||||
|
||||
authCodeURL := makeAuthCodeURL(t, oauthClient, "", false)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, authCodeURL, nil)
|
||||
require.NoError(t, err)
|
||||
res, err := browserClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, res.Body.Close())
|
||||
require.Equal(t, "", string(body))
|
||||
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||
}
|
||||
|
||||
registerNewAccount := func(t *testing.T, browserClient *http.Client, identifier, password string) {
|
||||
// we need to create a new session directly with kratos
|
||||
f := testhelpers.InitializeRegistrationFlowViaBrowser(t, browserClient, kratosPublicTS, false, false, false)
|
||||
require.NotNil(t, f)
|
||||
|
||||
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
||||
values.Set("traits.foobar", identifier)
|
||||
values.Set("traits.username", identifier)
|
||||
values.Set("password", password)
|
||||
|
||||
_, resp := testhelpers.RegistrationMakeRequest(t, false, false, f, browserClient, values.Encode())
|
||||
require.EqualValues(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var cookie *http.Cookie
|
||||
currentURL, err := url.Parse(kratosPublicTS.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, c := range browserClient.Jar.Cookies(currentURL) {
|
||||
if c.Name == config.DefaultSessionCookieName {
|
||||
cookie = c
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(t, cookie, "expected exactly one session cookie to be set but got none")
|
||||
}
|
||||
|
||||
// important, we will set the persistent cookie to false for most of the tests here
|
||||
// once this is true, the behavior of the consent flow changes
|
||||
conf.MustSet(ctx, config.ViperKeySessionPersistentCookie, false)
|
||||
|
||||
t.Run("case=should accept oauth login request on registration", func(t *testing.T) {
|
||||
// this test initiates a new OAuth2 flow which goes directly to the registration page
|
||||
// we then create a new account through the registration flow
|
||||
// and expect the OAuth2 flow to succeed
|
||||
scopes := []string{"profile", "email"}
|
||||
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), false)
|
||||
|
||||
oauth2Client := &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: "client-secret",
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: hydraPublic + "/oauth2/auth",
|
||||
TokenURL: hydraPublic + "/oauth2/token",
|
||||
AuthStyle: oauth2.AuthStyleInParams,
|
||||
},
|
||||
Scopes: scopes,
|
||||
RedirectURL: clientAppTS.URL,
|
||||
}
|
||||
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
|
||||
|
||||
identifier := x.NewUUID().String()
|
||||
password := x.NewUUID().String()
|
||||
|
||||
ct := make([]callTrace, 0)
|
||||
|
||||
ctx = context.WithValue(ctx, TestUIConfig, &testConfig{
|
||||
identifier: identifier,
|
||||
password: password,
|
||||
browserClient: browserClient,
|
||||
kratosPublicTS: kratosPublicTS,
|
||||
clientAppTS: clientAppTS,
|
||||
hydraAdminClient: hydraAdminClient,
|
||||
consentRemember: true,
|
||||
callTrace: &ct,
|
||||
requestedScope: scopes,
|
||||
})
|
||||
|
||||
clientAS := clientAppState{
|
||||
visits: 0,
|
||||
tokens: 0,
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, TestOAuthClientState, &clientAppConfig{
|
||||
client: oauth2Client,
|
||||
expectToken: true,
|
||||
state: &clientAS,
|
||||
})
|
||||
|
||||
doOAuthFlow(t, ctx,
|
||||
oauth2Client,
|
||||
browserClient)
|
||||
|
||||
assert.EqualValues(t, clientAppState{
|
||||
visits: 1,
|
||||
tokens: 1,
|
||||
}, clientAS)
|
||||
|
||||
expected := []callTrace{
|
||||
RegistrationUI,
|
||||
RegistrationWithOAuth2LoginChallenge,
|
||||
RegistrationUI,
|
||||
RegistrationWithFlowID,
|
||||
Consent,
|
||||
ConsentWithChallenge,
|
||||
ConsentAccept,
|
||||
CodeExchange,
|
||||
CodeExchangeWithToken,
|
||||
}
|
||||
require.ElementsMatch(t, expected, ct, "expected the call trace to match")
|
||||
})
|
||||
|
||||
t.Run("case=registration with session should redirect to login to re-authenticate and to consent", func(t *testing.T) {
|
||||
// this test registers a new account which sets a session
|
||||
// we then initiate a new OAuth2 flow which should redirect us to the registration page
|
||||
// the registration page does a session validation and retrieves the loginRequest from Hydra
|
||||
// which in this case will indicate we cannot skip the login flow (since the there is no previous OAuth flow associated)
|
||||
// we then get redirected to the the login page with refresh=true
|
||||
// we then sign in and expect to be redirected to the consent page
|
||||
// and then back to the client app
|
||||
scopes := []string{"profile", "email", "offline_access"}
|
||||
|
||||
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), false)
|
||||
|
||||
oauthClient := &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: "client-secret",
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: hydraPublic + "/oauth2/auth",
|
||||
TokenURL: hydraPublic + "/oauth2/token",
|
||||
AuthStyle: oauth2.AuthStyleInParams,
|
||||
},
|
||||
Scopes: scopes,
|
||||
RedirectURL: clientAppTS.URL,
|
||||
}
|
||||
|
||||
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
|
||||
identifier := x.NewUUID().String()
|
||||
password := x.NewUUID().String()
|
||||
|
||||
ct := make([]callTrace, 0)
|
||||
|
||||
tc := &testConfig{
|
||||
identifier: identifier,
|
||||
password: password,
|
||||
browserClient: browserClient,
|
||||
kratosPublicTS: kratosPublicTS,
|
||||
clientAppTS: clientAppTS,
|
||||
hydraAdminClient: hydraAdminClient,
|
||||
consentRemember: true,
|
||||
requestedScope: scopes,
|
||||
callTrace: &ct,
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, TestUIConfig, tc)
|
||||
|
||||
clientAS := clientAppState{
|
||||
visits: 0,
|
||||
tokens: 0,
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, TestOAuthClientState, &clientAppConfig{
|
||||
client: oauthClient,
|
||||
expectToken: true,
|
||||
state: &clientAS,
|
||||
})
|
||||
|
||||
registerNewAccount(t, browserClient, identifier, password)
|
||||
|
||||
require.ElementsMatch(t, []callTrace{
|
||||
RegistrationUI,
|
||||
RegistrationWithFlowID,
|
||||
}, ct, "expected the call trace to match")
|
||||
|
||||
// reset the call trace
|
||||
ct = []callTrace{}
|
||||
tc.callTrace = &ct
|
||||
|
||||
doOAuthFlow(t, ctx, oauthClient, browserClient)
|
||||
|
||||
assert.EqualValues(t, clientAppState{
|
||||
visits: 1,
|
||||
tokens: 1,
|
||||
}, clientAS)
|
||||
|
||||
expected := []callTrace{
|
||||
RegistrationUI,
|
||||
RegistrationWithOAuth2LoginChallenge,
|
||||
LoginUI,
|
||||
LoginWithFlowID,
|
||||
Consent,
|
||||
ConsentWithChallenge,
|
||||
ConsentAccept,
|
||||
CodeExchange,
|
||||
CodeExchangeWithToken,
|
||||
}
|
||||
require.ElementsMatch(t, expected, ct, "expected the call trace to match")
|
||||
})
|
||||
|
||||
t.Run("case=registration should redirect to login if session exists and skip=false", func(t *testing.T) {
|
||||
// we dont want to skip the consent page here
|
||||
// we want the registration page to redirect to the login page
|
||||
// since we have a session but do not skip the consent page
|
||||
clientSkipConsent := false
|
||||
consentRemember := false
|
||||
|
||||
scopes := []string{"profile", "email", "offline_access"}
|
||||
|
||||
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), clientSkipConsent)
|
||||
oauthClient := &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: "client-secret",
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: hydraPublic + "/oauth2/auth",
|
||||
TokenURL: hydraPublic + "/oauth2/token",
|
||||
AuthStyle: oauth2.AuthStyleInParams,
|
||||
},
|
||||
Scopes: scopes,
|
||||
RedirectURL: clientAppTS.URL,
|
||||
}
|
||||
|
||||
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
|
||||
identifier := x.NewUUID().String()
|
||||
password := x.NewUUID().String()
|
||||
|
||||
ct := make([]callTrace, 0)
|
||||
|
||||
tc := &testConfig{
|
||||
identifier: identifier,
|
||||
password: password,
|
||||
browserClient: browserClient,
|
||||
kratosPublicTS: kratosPublicTS,
|
||||
clientAppTS: clientAppTS,
|
||||
hydraAdminClient: hydraAdminClient,
|
||||
consentRemember: consentRemember,
|
||||
requestedScope: scopes,
|
||||
callTrace: &ct,
|
||||
}
|
||||
|
||||
clientAppConfig := &clientAppConfig{
|
||||
client: oauthClient,
|
||||
expectToken: true,
|
||||
}
|
||||
|
||||
expected := []callTrace{
|
||||
RegistrationUI,
|
||||
RegistrationWithOAuth2LoginChallenge,
|
||||
RegistrationUI,
|
||||
RegistrationWithFlowID,
|
||||
Consent,
|
||||
ConsentWithChallenge,
|
||||
ConsentAccept,
|
||||
CodeExchange,
|
||||
CodeExchangeWithToken,
|
||||
}
|
||||
|
||||
// set the global context values
|
||||
ctx = context.WithValue(ctx, TestUIConfig, tc)
|
||||
ctx = context.WithValue(ctx, TestOAuthClientState, clientAppConfig)
|
||||
|
||||
doSuccessfulOAuthFlow := func(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
clientAS := clientAppState{
|
||||
visits: 0,
|
||||
tokens: 0,
|
||||
}
|
||||
clientAppConfig.state = &clientAS
|
||||
|
||||
doOAuthFlow(t, ctx,
|
||||
oauthClient,
|
||||
browserClient)
|
||||
assert.EqualValues(t, clientAppState{
|
||||
visits: 1,
|
||||
tokens: 1,
|
||||
}, clientAS)
|
||||
|
||||
require.ElementsMatchf(t, expected, ct, "expected the call trace to match")
|
||||
}
|
||||
|
||||
doSuccessfulOAuthFlow(t)
|
||||
|
||||
// reset our state on the client app
|
||||
clientAS := clientAppState{
|
||||
visits: 0,
|
||||
tokens: 0,
|
||||
}
|
||||
|
||||
clientAppConfig.state = &clientAS
|
||||
|
||||
// we should now have a session, but not skip the consent page
|
||||
doOAuthFlow(t, ctx, oauthClient, browserClient)
|
||||
assert.EqualValues(t, clientAppState{
|
||||
visits: 1,
|
||||
tokens: 1,
|
||||
}, clientAS)
|
||||
|
||||
expected = append(expected,
|
||||
RegistrationUI,
|
||||
RegistrationWithOAuth2LoginChallenge,
|
||||
LoginUI,
|
||||
LoginWithFlowID,
|
||||
Consent,
|
||||
ConsentWithChallenge,
|
||||
ConsentAccept,
|
||||
CodeExchange,
|
||||
CodeExchangeWithToken,
|
||||
)
|
||||
require.ElementsMatchf(t, expected, ct, "expected the call trace to match")
|
||||
})
|
||||
|
||||
t.Run("case=consent should be skipped if client is configured to skip", func(t *testing.T) {
|
||||
clientSkipConsent := true
|
||||
scopes := []string{"profile", "email", "offline_access"}
|
||||
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), clientSkipConsent)
|
||||
|
||||
oauthClient := &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: "client-secret",
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: hydraPublic + "/oauth2/auth",
|
||||
TokenURL: hydraPublic + "/oauth2/token",
|
||||
AuthStyle: oauth2.AuthStyleInParams,
|
||||
},
|
||||
Scopes: scopes,
|
||||
RedirectURL: clientAppTS.URL,
|
||||
}
|
||||
|
||||
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
|
||||
identifier := x.NewUUID().String()
|
||||
password := x.NewUUID().String()
|
||||
|
||||
ct := make([]callTrace, 0)
|
||||
|
||||
tc := &testConfig{
|
||||
identifier: identifier,
|
||||
password: password,
|
||||
browserClient: browserClient,
|
||||
kratosPublicTS: kratosPublicTS,
|
||||
clientAppTS: clientAppTS,
|
||||
hydraAdminClient: hydraAdminClient,
|
||||
consentRemember: false,
|
||||
requestedScope: scopes,
|
||||
callTrace: &ct,
|
||||
}
|
||||
|
||||
clientAppConfig := &clientAppConfig{
|
||||
client: oauthClient,
|
||||
expectToken: true,
|
||||
}
|
||||
|
||||
expected := []callTrace{
|
||||
RegistrationUI,
|
||||
RegistrationWithOAuth2LoginChallenge,
|
||||
RegistrationUI,
|
||||
RegistrationWithFlowID,
|
||||
Consent,
|
||||
ConsentWithChallenge,
|
||||
ConsentClientSkip,
|
||||
ConsentAccept,
|
||||
CodeExchange,
|
||||
CodeExchangeWithToken,
|
||||
}
|
||||
|
||||
// set the global context values
|
||||
ctx = context.WithValue(ctx, TestUIConfig, tc)
|
||||
ctx = context.WithValue(ctx, TestOAuthClientState, clientAppConfig)
|
||||
|
||||
clientAS := clientAppState{
|
||||
visits: 0,
|
||||
tokens: 0,
|
||||
}
|
||||
clientAppConfig.state = &clientAS
|
||||
|
||||
doOAuthFlow(t, ctx,
|
||||
oauthClient,
|
||||
browserClient)
|
||||
assert.EqualValues(t, clientAppState{
|
||||
visits: 1,
|
||||
tokens: 1,
|
||||
}, clientAS)
|
||||
|
||||
require.ElementsMatchf(t, expected, ct, "expected the call trace to match")
|
||||
})
|
||||
|
||||
t.Run("case=consent should be skipped if user has a session and has already consented", func(t *testing.T) {
|
||||
conf.MustSet(ctx, config.ViperKeySessionPersistentCookie, true)
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(ctx, config.ViperKeySessionPersistentCookie, false)
|
||||
})
|
||||
|
||||
consentRemember := true
|
||||
|
||||
scopes := []string{"profile", "email", "offline_access"}
|
||||
|
||||
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), false)
|
||||
oauthClient := &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: "client-secret",
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: hydraPublic + "/oauth2/auth",
|
||||
TokenURL: hydraPublic + "/oauth2/token",
|
||||
AuthStyle: oauth2.AuthStyleInParams,
|
||||
},
|
||||
Scopes: scopes,
|
||||
RedirectURL: clientAppTS.URL,
|
||||
}
|
||||
|
||||
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
|
||||
identifier := x.NewUUID().String()
|
||||
password := x.NewUUID().String()
|
||||
|
||||
ct := make([]callTrace, 0)
|
||||
|
||||
kratosUIConfig := &testConfig{
|
||||
identifier: identifier,
|
||||
password: password,
|
||||
browserClient: browserClient,
|
||||
kratosPublicTS: kratosPublicTS,
|
||||
clientAppTS: clientAppTS,
|
||||
hydraAdminClient: hydraAdminClient,
|
||||
consentRemember: consentRemember,
|
||||
requestedScope: scopes,
|
||||
callTrace: &ct,
|
||||
}
|
||||
|
||||
clientAppConfig := &clientAppConfig{
|
||||
client: oauthClient,
|
||||
expectToken: true,
|
||||
}
|
||||
|
||||
expected := []callTrace{
|
||||
RegistrationUI,
|
||||
RegistrationWithOAuth2LoginChallenge,
|
||||
RegistrationUI,
|
||||
RegistrationWithFlowID,
|
||||
Consent,
|
||||
ConsentWithChallenge,
|
||||
ConsentAccept,
|
||||
CodeExchange,
|
||||
CodeExchangeWithToken,
|
||||
}
|
||||
|
||||
// set the global context values
|
||||
ctx = context.WithValue(ctx, TestUIConfig, kratosUIConfig)
|
||||
ctx = context.WithValue(ctx, TestOAuthClientState, clientAppConfig)
|
||||
|
||||
doSuccessfulOAuthFlow := func(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
clientAS := clientAppState{
|
||||
visits: 0,
|
||||
tokens: 0,
|
||||
}
|
||||
clientAppConfig.state = &clientAS
|
||||
|
||||
doOAuthFlow(t, ctx,
|
||||
oauthClient,
|
||||
browserClient)
|
||||
assert.EqualValues(t, clientAppState{
|
||||
visits: 1,
|
||||
tokens: 1,
|
||||
}, clientAS)
|
||||
|
||||
require.ElementsMatchf(t, expected, ct, "expected the call trace to match")
|
||||
}
|
||||
|
||||
doSuccessfulOAuthFlow(t)
|
||||
|
||||
// reset our state on the client app
|
||||
clientAS := clientAppState{
|
||||
visits: 0,
|
||||
tokens: 0,
|
||||
}
|
||||
|
||||
clientAppConfig.state = &clientAS
|
||||
|
||||
// reset the call trace
|
||||
ct = []callTrace{}
|
||||
kratosUIConfig.callTrace = &ct
|
||||
|
||||
// we should now have a session, but not skip the consent page
|
||||
doOAuthFlow(t, ctx, oauthClient, browserClient)
|
||||
assert.EqualValues(t, clientAppState{
|
||||
visits: 1,
|
||||
tokens: 1,
|
||||
}, clientAS)
|
||||
|
||||
expected = []callTrace{
|
||||
RegistrationUI,
|
||||
RegistrationWithOAuth2LoginChallenge,
|
||||
Consent,
|
||||
ConsentWithChallenge,
|
||||
ConsentSkip,
|
||||
ConsentAccept,
|
||||
CodeExchange,
|
||||
CodeExchangeWithToken,
|
||||
}
|
||||
require.ElementsMatchf(t, expected, ct, "expected the call trace to match")
|
||||
})
|
||||
|
||||
t.Run("case=should fail because the persistent Hydra session doesn't match the new Kratos session subject", func(t *testing.T) {
|
||||
conf.MustSet(ctx, config.ViperKeySessionPersistentCookie, true)
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(ctx, config.ViperKeySessionPersistentCookie, false)
|
||||
})
|
||||
|
||||
// this test re-uses the previous oauthClient
|
||||
// but creates a new user account through the registration flow
|
||||
// since the session with the new user does not match the hydra session it should fail
|
||||
scopes := []string{"profile", "email", "offline_access"}
|
||||
clientID := createOAuth2Client(t, ctx, hydraAdminClient, []string{clientAppTS.URL}, strings.Join(scopes, " "), false)
|
||||
|
||||
oauthClient := &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: "client-secret",
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: hydraPublic + "/oauth2/auth",
|
||||
TokenURL: hydraPublic + "/oauth2/token",
|
||||
AuthStyle: oauth2.AuthStyleInParams,
|
||||
},
|
||||
Scopes: scopes,
|
||||
RedirectURL: clientAppTS.URL,
|
||||
}
|
||||
|
||||
browserClient := testhelpers.NewClientWithCookieJar(t, nil, nil)
|
||||
|
||||
ct := make([]callTrace, 0)
|
||||
|
||||
kratosUIConfig := &testConfig{
|
||||
identifier: x.NewUUID().String(),
|
||||
password: x.NewUUID().String(),
|
||||
kratosPublicTS: kratosPublicTS,
|
||||
browserClient: browserClient,
|
||||
clientAppTS: clientAppTS,
|
||||
hydraAdminClient: hydraAdminClient,
|
||||
consentRemember: true,
|
||||
callTrace: &ct,
|
||||
requestedScope: scopes,
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, TestUIConfig, kratosUIConfig)
|
||||
|
||||
clientAppConfig := &clientAppConfig{
|
||||
client: oauthClient,
|
||||
expectToken: false,
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, TestOAuthClientState, clientAppConfig)
|
||||
|
||||
expected := []callTrace{
|
||||
RegistrationUI,
|
||||
RegistrationWithOAuth2LoginChallenge,
|
||||
RegistrationUI,
|
||||
RegistrationWithFlowID,
|
||||
Consent,
|
||||
ConsentWithChallenge,
|
||||
ConsentAccept,
|
||||
CodeExchange,
|
||||
CodeExchangeWithToken,
|
||||
}
|
||||
|
||||
doSuccessfulOAuthFlow := func(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
clientAS := clientAppState{
|
||||
visits: 0,
|
||||
tokens: 0,
|
||||
}
|
||||
clientAppConfig.state = &clientAS
|
||||
|
||||
doOAuthFlow(t, ctx,
|
||||
oauthClient,
|
||||
browserClient)
|
||||
|
||||
assert.EqualValues(t, clientAppState{
|
||||
visits: 1,
|
||||
tokens: 1,
|
||||
}, clientAS)
|
||||
|
||||
require.ElementsMatch(t, expected, ct, "expected the call trace to match")
|
||||
}
|
||||
|
||||
doSuccessfulOAuthFlow(t)
|
||||
|
||||
clientAS := clientAppState{
|
||||
visits: 0,
|
||||
tokens: 0,
|
||||
}
|
||||
clientAppConfig.state = &clientAS
|
||||
|
||||
currentURL, err := url.Parse(kratosPublicTS.URL)
|
||||
require.NoError(t, err)
|
||||
cookies := browserClient.Jar.Cookies(currentURL)
|
||||
|
||||
// remove the kratos session so we can register a new account
|
||||
for _, c := range cookies {
|
||||
if c.Name == config.DefaultSessionCookieName {
|
||||
c.MaxAge = -1
|
||||
c.Value = ""
|
||||
break
|
||||
}
|
||||
}
|
||||
browserClient.Jar.SetCookies(currentURL, cookies)
|
||||
|
||||
kratosUIConfig.identifier = x.NewUUID().String()
|
||||
kratosUIConfig.password = x.NewUUID().String()
|
||||
|
||||
// reset the call trace
|
||||
ct = []callTrace{}
|
||||
kratosUIConfig.callTrace = &ct
|
||||
|
||||
doOAuthFlow(t, ctx,
|
||||
oauthClient,
|
||||
browserClient)
|
||||
|
||||
// In Hydra v2.2.0-rc.3 this would have failed outright: Hydra returned an error
|
||||
// when AcceptLoginRequest arrived with a subject (new user B) that did not match
|
||||
// the subject in the existing login session (user A, skip=true flow).
|
||||
//
|
||||
// In Hydra v2.2.0 final the mismatch is handled with a prompt=login redirect
|
||||
// instead of an error. The restart produces a fresh flow with f.Subject="" so
|
||||
// the mismatch guard (f.Subject != "" && payload.Subject != f.Subject) in
|
||||
// consent/handler.go does not fire on the second attempt, and the new user's
|
||||
// subject is accepted. The flow completes successfully for user B.
|
||||
//
|
||||
// Security note: this is not exploitable in production. AcceptLoginRequest is a
|
||||
// trusted admin API reachable only by the login provider (Kratos), which always
|
||||
// sends the correctly authenticated identity ID. The change removes a hard
|
||||
// early-failure signal for accidental subject substitution but does not
|
||||
// introduce a new attack surface.
|
||||
assert.EqualValues(t, clientAppState{
|
||||
visits: 1,
|
||||
tokens: 1,
|
||||
}, clientAS)
|
||||
|
||||
expected = []callTrace{
|
||||
RegistrationUI,
|
||||
RegistrationWithOAuth2LoginChallenge, // skip=true, subject mismatch → prompt=login redirect
|
||||
RegistrationUI,
|
||||
RegistrationWithFlowID,
|
||||
RegistrationUI,
|
||||
RegistrationWithOAuth2LoginChallenge, // skip=false, f.Subject="" → guard bypassed, new subject accepted
|
||||
LoginUI,
|
||||
LoginWithFlowID,
|
||||
Consent,
|
||||
ConsentWithChallenge,
|
||||
ConsentAccept,
|
||||
CodeExchange,
|
||||
CodeExchangeWithToken,
|
||||
}
|
||||
require.ElementsMatch(t, expected, ct, "expected the call trace to match")
|
||||
})
|
||||
}
|
||||
250
selfservice/strategy/password/registration.go
Normal file
250
selfservice/strategy/password/registration.go
Normal file
@@ -0,0 +1,250 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/ory/x/otelx/semconv"
|
||||
|
||||
"github.com/ory/x/otelx"
|
||||
|
||||
"github.com/ory/kratos/text"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"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/registration"
|
||||
"github.com/ory/kratos/ui/container"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
"github.com/ory/kratos/x"
|
||||
)
|
||||
|
||||
func nodePasswordInput() *node.Node {
|
||||
return NewPasswordNode("password", node.InputAttributeAutocompleteNewPassword)
|
||||
}
|
||||
|
||||
func nodeSubmit() *node.Node {
|
||||
return node.NewInputField("method", "password", node.PasswordGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoRegistration())
|
||||
}
|
||||
|
||||
var _ registration.FormHydrator = new(Strategy)
|
||||
|
||||
// Update Registration Flow with Password Method
|
||||
//
|
||||
// swagger:model updateRegistrationFlowWithPasswordMethod
|
||||
type UpdateRegistrationFlowWithPasswordMethod struct {
|
||||
// Password to sign the user up with
|
||||
//
|
||||
// required: true
|
||||
Password string `json:"password"`
|
||||
|
||||
// The identity's traits
|
||||
//
|
||||
// required: true
|
||||
Traits json.RawMessage `json:"traits"`
|
||||
|
||||
// The CSRF Token
|
||||
CSRFToken string `json:"csrf_token"`
|
||||
|
||||
// Method to use
|
||||
//
|
||||
// This field must be set to `password` when using the password method.
|
||||
//
|
||||
// required: true
|
||||
Method string `json:"method"`
|
||||
|
||||
// Transient data to pass along to any webhooks
|
||||
//
|
||||
// required: false
|
||||
TransientPayload json.RawMessage `json:"transient_payload,omitempty" form:"transient_payload"`
|
||||
}
|
||||
|
||||
func (s *Strategy) handleRegistrationError(r *http.Request, f *registration.Flow, p UpdateRegistrationFlowWithPasswordMethod, err error) error {
|
||||
if f != nil {
|
||||
for _, n := range container.NewFromJSON("", node.DefaultGroup, p.Traits, "traits").Nodes {
|
||||
// we only set the value and not the whole field because we want to keep types from the initial form generation
|
||||
f.UI.Nodes.SetValueAttribute(n.ID(), n.Attributes.GetValue())
|
||||
}
|
||||
|
||||
if f.Type == flow.TypeBrowser {
|
||||
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Strategy) decode(p *UpdateRegistrationFlowWithPasswordMethod, r *http.Request, ds *url.URL) (err error) {
|
||||
return registration.DecodeBody(p, r, registrationSchema, ds)
|
||||
}
|
||||
|
||||
func (s *Strategy) Register(_ http.ResponseWriter, r *http.Request, f *registration.Flow, i *identity.Identity) (err error) {
|
||||
ctx, span := s.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.strategy.password.Strategy.Register")
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
if err := flow.MethodEnabledAndAllowedFromRequest(r, f.GetFlowName(), s.ID().String(), s.d); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ds, err := f.IdentitySchema.URL(ctx, s.d.Config())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var p UpdateRegistrationFlowWithPasswordMethod
|
||||
if err := s.decode(&p, r, ds); err != nil {
|
||||
return s.handleRegistrationError(r, f, p, err)
|
||||
}
|
||||
|
||||
f.TransientPayload = p.TransientPayload
|
||||
|
||||
if err := flow.EnsureCSRF(s.d, r, f.Type, s.d.Config().DisableAPIFlowEnforcement(ctx), s.d.GenerateCSRFToken, p.CSRFToken); err != nil {
|
||||
return s.handleRegistrationError(r, f, p, err)
|
||||
}
|
||||
|
||||
if len(p.Password) == 0 {
|
||||
return s.handleRegistrationError(r, f, p, schema.NewRequiredError("#/password", "password"))
|
||||
}
|
||||
|
||||
if len(p.Traits) == 0 {
|
||||
p.Traits = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
hpw := make(chan []byte)
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
defer close(hpw)
|
||||
defer close(errC)
|
||||
|
||||
h, err := s.d.Hasher(ctx).Generate(ctx, []byte(p.Password))
|
||||
if err != nil {
|
||||
errC <- err
|
||||
return
|
||||
}
|
||||
hpw <- h
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
return s.handleRegistrationError(r, f, p, err)
|
||||
}
|
||||
|
||||
i.Traits = identity.Traits(p.Traits)
|
||||
// We have to set the credential here, so the identity validator can populate the identifiers.
|
||||
// The password hash is computed in parallel and set later.
|
||||
if err := i.SetCredentialsWithConfig(s.ID(), identity.Credentials{Type: s.ID(), Identifiers: []string{}}, json.RawMessage("{}")); err != nil {
|
||||
return s.handleRegistrationError(r, f, p, err)
|
||||
}
|
||||
|
||||
if err := s.validateCredentials(ctx, i, p.Password); err != nil {
|
||||
return s.handleRegistrationError(r, f, p, err)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-errC:
|
||||
return s.handleRegistrationError(r, f, p, err)
|
||||
case h := <-hpw:
|
||||
co, err := json.Marshal(&identity.CredentialsPassword{HashedPassword: string(h)})
|
||||
if err != nil {
|
||||
return s.handleRegistrationError(r, f, p, errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to encode password options to JSON: %s", err)))
|
||||
}
|
||||
i.UpsertCredentialsConfig(s.ID(), co, 0)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Strategy) validateCredentials(ctx context.Context, i *identity.Identity, pw string) (err error) {
|
||||
ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.password.Strategy.validateCredentials")
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
if err := s.d.IdentityValidator().Validate(ctx, i); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c, ok := i.GetCredentials(identity.CredentialsTypePassword)
|
||||
if !ok {
|
||||
// This should never happen
|
||||
return errors.WithStack(x.PseudoPanic.WithReasonf("identity object did not provide the %s CredentialType unexpectedly", identity.CredentialsTypePassword))
|
||||
} else if len(c.Identifiers) == 0 {
|
||||
return schema.NewMissingIdentifierError()
|
||||
}
|
||||
|
||||
for _, id := range c.Identifiers {
|
||||
if err := s.d.PasswordValidator().Validate(ctx, id, pw); err != nil {
|
||||
if herodotErr := new(herodot.DefaultError); errors.As(err, &herodotErr) {
|
||||
return err
|
||||
}
|
||||
if message := new(text.Message); errors.As(err, &message) {
|
||||
return schema.NewPasswordPolicyViolationError("#/password", message)
|
||||
}
|
||||
return schema.NewPasswordPolicyViolationError("#/password", text.NewErrorValidationPasswordPolicyViolationGeneric(err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Strategy) PopulateRegistrationMethod(r *http.Request, f *registration.Flow) (err error) {
|
||||
ctx, span := s.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.strategy.password.Strategy.PopulateRegistrationMethod")
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
ds, err := f.IdentitySchema.URL(ctx, s.d.Config())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The group used to be `password`, but to make it consistent with other methods and two-step registration,
|
||||
// it is now `default`. To make this switch backwards compatible, this feature flag is used.
|
||||
//
|
||||
// Previously, the behavior would be that the group of NodesFromJSONSchema would be `password`, but as soon
|
||||
// as any other method (code, passkeys, two-step) would be enabled, the group would be `default`.
|
||||
//
|
||||
// Going forward, the default is that the group is `default` and the feature flag is not set.
|
||||
//
|
||||
// TODO remove me when everyone has migrated.
|
||||
if !s.d.Config().SelfServiceFlowRegistrationTwoSteps(r.Context()) && node.UiNodeGroup(s.d.Config().SelfServiceFlowRegistrationPasswordMethodProfileGroup(r.Context())) == node.PasswordGroup {
|
||||
span.AddEvent(semconv.NewDeprecatedFeatureUsedEvent(ctx, "password_profile_registration_node_group=password"))
|
||||
|
||||
// This is the legacy code path. In the new code path, the profile method is responsible for hydrating the form
|
||||
// nodes. In the old code path, the password method is responsible for hydrating the form nodes if it is
|
||||
// the only method enabled.
|
||||
nodes, err := container.NodesFromJSONSchema(r.Context(), node.PasswordGroup, ds.String(), "", nil, s.d.Config().SecurityDisallowRefInIdentitySchemas(r.Context()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, n := range nodes {
|
||||
f.UI.SetNode(n)
|
||||
}
|
||||
}
|
||||
// TODO end
|
||||
|
||||
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
||||
f.UI.Nodes.Upsert(nodePasswordInput())
|
||||
f.UI.Nodes.Upsert(nodeSubmit())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Strategy) PopulateRegistrationMethodCredentials(r *http.Request, f *registration.Flow, options ...registration.FormHydratorModifier) error {
|
||||
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
||||
f.UI.Nodes.Upsert(nodePasswordInput())
|
||||
f.UI.Nodes.Upsert(nodeSubmit())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Strategy) PopulateRegistrationMethodProfile(r *http.Request, f *registration.Flow, options ...registration.FormHydratorModifier) error {
|
||||
// The profile method is responsible for rendering the profile form fields.
|
||||
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
||||
f.UI.Nodes.RemoveMatching(nodePasswordInput())
|
||||
f.UI.Nodes.RemoveMatching(nodeSubmit())
|
||||
return nil
|
||||
}
|
||||
985
selfservice/strategy/password/registration_test.go
Normal file
985
selfservice/strategy/password/registration_test.go
Normal file
@@ -0,0 +1,985 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/kratos/driver"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/pkg"
|
||||
kratos "github.com/ory/kratos/pkg/httpclient"
|
||||
"github.com/ory/kratos/pkg/registrationhelpers"
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/selfservice/flow/login"
|
||||
"github.com/ory/kratos/selfservice/flow/registration"
|
||||
"github.com/ory/kratos/text"
|
||||
"github.com/ory/kratos/ui/container"
|
||||
"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/snapshotx"
|
||||
"github.com/ory/x/sqlcon"
|
||||
)
|
||||
|
||||
var flows = []string{"spa", "api", "browser"}
|
||||
|
||||
func newRegistrationRegistry(t *testing.T, cfgOpts ...configx.OptionModifier) *driver.RegistryDefault {
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t, append([]configx.OptionModifier{
|
||||
configx.WithValues(map[string]any{
|
||||
config.ViperKeySelfServiceStrategyConfig + "." + string(identity.CredentialsTypePassword) + ".enabled": true,
|
||||
config.ViperKeySelfServiceRegistrationLoginHints: true,
|
||||
config.ViperKeySelfServiceRegistrationEnableLegacyOneStep: true,
|
||||
}),
|
||||
}, cfgOpts...)...)
|
||||
|
||||
return reg
|
||||
}
|
||||
|
||||
func TestRegistration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("case=registration", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reg := newRegistrationRegistry(t, configx.WithValues(map[string]any{
|
||||
config.ViperKeySelfServiceRegistrationEnableLegacyOneStep: true,
|
||||
config.ViperKeyDefaultIdentitySchemaID: "default",
|
||||
config.ViperKeyIdentitySchemas: config.Schemas{
|
||||
{ID: "default", URL: "file://stub/registration.schema.json", SelfserviceSelectable: true},
|
||||
{ID: "other", URL: "file://stub/registration.schema.json", SelfserviceSelectable: true},
|
||||
},
|
||||
}))
|
||||
conf := reg.Config()
|
||||
|
||||
publicTS, _ := testhelpers.NewKratosServer(t, reg)
|
||||
_ = testhelpers.NewErrorTestServer(t, reg)
|
||||
_ = testhelpers.NewRegistrationUIFlowEchoServer(t, reg)
|
||||
redirTS := testhelpers.NewRedirSessionEchoTS(t, reg)
|
||||
redirNoSessionTS := testhelpers.NewRedirNoSessionTS(t, reg)
|
||||
|
||||
// set the "return to" server, which will assert the session state
|
||||
// (redirTS: enforce that a session exists, redirNoSessionTS: enforce that no session exists)
|
||||
useReturnToFromTS := func(ts *httptest.Server) {
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceBrowserDefaultReturnTo, ts.URL+"/default-return-to")
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceRegistrationAfter+"."+config.DefaultBrowserReturnURL, ts.URL+"/registration-return-ts")
|
||||
}
|
||||
|
||||
useReturnToFromTS(redirTS)
|
||||
|
||||
apiClient := testhelpers.NewDebugClient(t)
|
||||
|
||||
t.Run("AssertCommonErrorCases", func(t *testing.T) {
|
||||
registrationhelpers.AssertCommonErrorCases(t, flows)
|
||||
})
|
||||
|
||||
t.Run("AssertRegistrationRespectsValidation", func(t *testing.T) {
|
||||
reg := newRegistrationRegistry(t)
|
||||
registrationhelpers.AssertRegistrationRespectsValidation(t, reg, flows, func(v url.Values) {
|
||||
v.Del("traits.foobar")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("AssertCSRFFailures", func(t *testing.T) {
|
||||
reg := newRegistrationRegistry(t)
|
||||
registrationhelpers.AssertCSRFFailures(t, reg, flows, func(v url.Values) {
|
||||
v.Set("password", x.NewUUID().String())
|
||||
v.Set("method", identity.CredentialsTypePassword.String())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("AssertSchemaDoesNotExist", func(t *testing.T) {
|
||||
reg := newRegistrationRegistry(t)
|
||||
registrationhelpers.AssertSchemaDoesNotExist(t, reg, flows, func(v url.Values) {
|
||||
v.Set("password", x.NewUUID().String())
|
||||
v.Set("method", identity.CredentialsTypePassword.String())
|
||||
})
|
||||
})
|
||||
|
||||
expectRegistrationBody := func(t *testing.T, browserRedirTS *httptest.Server, isAPI, isSPA bool, hc *http.Client, values func(url.Values)) string {
|
||||
if isAPI {
|
||||
return testhelpers.SubmitRegistrationForm(t, isAPI, hc, publicTS, values,
|
||||
isSPA, http.StatusOK,
|
||||
publicTS.URL+registration.RouteSubmitFlow)
|
||||
}
|
||||
|
||||
if hc == nil {
|
||||
hc = testhelpers.NewClientWithCookies(t)
|
||||
}
|
||||
|
||||
expectReturnTo := browserRedirTS.URL + "/registration-return-ts"
|
||||
if isSPA {
|
||||
expectReturnTo = publicTS.URL
|
||||
}
|
||||
|
||||
return testhelpers.SubmitRegistrationForm(t, isAPI, hc, publicTS, values,
|
||||
isSPA, http.StatusOK, expectReturnTo)
|
||||
}
|
||||
|
||||
expectSuccessfulRegistration := func(t *testing.T, isAPI, isSPA bool, hc *http.Client, values func(url.Values)) string {
|
||||
useReturnToFromTS(redirTS)
|
||||
return expectRegistrationBody(t, redirTS, isAPI, isSPA, hc, values)
|
||||
}
|
||||
|
||||
expectNoRegistration := func(t *testing.T, isAPI, isSPA bool, hc *http.Client, values func(url.Values)) string {
|
||||
useReturnToFromTS(redirNoSessionTS)
|
||||
t.Cleanup(func() {
|
||||
useReturnToFromTS(redirTS)
|
||||
})
|
||||
return expectRegistrationBody(t, redirNoSessionTS, isAPI, isSPA, hc, values)
|
||||
}
|
||||
|
||||
t.Run("case=should reject invalid transient payload", func(t *testing.T) {
|
||||
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json")
|
||||
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), []config.SelfServiceHook{{Name: "session"}})
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil)
|
||||
})
|
||||
|
||||
for _, f := range flows {
|
||||
t.Run("type="+f, func(t *testing.T) {
|
||||
username := x.NewUUID().String()
|
||||
body := registrationhelpers.ExpectValidationError(t.Context(), t, publicTS, conf, f, func(v url.Values) {
|
||||
v.Set("traits.username", username)
|
||||
v.Set("password", x.NewUUID().String())
|
||||
v.Set("traits.foobar", "bar")
|
||||
v.Set("transient_payload", "42")
|
||||
})
|
||||
assert.Equal(t, "bar", gjson.Get(body, "ui.nodes.#(attributes.name==traits.foobar).attributes.value").String(), "%s", body)
|
||||
assert.Equal(t, username, gjson.Get(body, "ui.nodes.#(attributes.name==traits.username).attributes.value").String(), "%s", body)
|
||||
assert.Equal(t, int64(4000026), gjson.Get(body, "ui.nodes.#(attributes.name==transient_payload).messages.0.id").Int(), "%s", body)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case=should accept valid transient payload", func(t *testing.T) {
|
||||
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json")
|
||||
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), []config.SelfServiceHook{{Name: "session"}})
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil)
|
||||
})
|
||||
|
||||
setValues := func(username string, v url.Values) {
|
||||
v.Set("traits.username", username)
|
||||
v.Set("password", x.NewUUID().String())
|
||||
v.Set("traits.foobar", "bar")
|
||||
v.Set("transient_payload.stuff", "42")
|
||||
}
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
username := x.NewUUID().String()
|
||||
body := expectSuccessfulRegistration(t, true, false, nil, func(v url.Values) {
|
||||
setValues(username, v)
|
||||
})
|
||||
assert.Equal(t, username, gjson.Get(body, "identity.traits.username").String(), "%s", body)
|
||||
assert.NotEmpty(t, gjson.Get(body, "session_token").String(), "%s", body)
|
||||
assert.NotEmpty(t, gjson.Get(body, "session.id").String(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
username := x.NewUUID().String()
|
||||
body := expectSuccessfulRegistration(t, false, true, nil, func(v url.Values) {
|
||||
setValues(username, v)
|
||||
})
|
||||
assert.Equal(t, username, gjson.Get(body, "identity.traits.username").String(), "%s", body)
|
||||
assert.Empty(t, gjson.Get(body, "session_token").String(), "%s", body)
|
||||
assert.NotEmpty(t, gjson.Get(body, "session.id").String(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
username := x.NewUUID().String()
|
||||
body := expectSuccessfulRegistration(t, false, false, nil, func(v url.Values) {
|
||||
setValues(username, v)
|
||||
})
|
||||
assert.Equal(t, username, gjson.Get(body, "identity.traits.username").String(), "%s", body)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=should pass and set up a session", func(t *testing.T) {
|
||||
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json")
|
||||
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), []config.SelfServiceHook{{Name: "session"}})
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil)
|
||||
})
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
body := expectSuccessfulRegistration(t, true, false, nil, func(v url.Values) {
|
||||
v.Set("traits.username", "registration-identifier-8-api")
|
||||
v.Set("password", x.NewUUID().String())
|
||||
v.Set("traits.foobar", "bar")
|
||||
})
|
||||
assert.Equal(t, `registration-identifier-8-api`, gjson.Get(body, "identity.traits.username").String(), "%s", body)
|
||||
assert.NotEmpty(t, gjson.Get(body, "session_token").String(), "%s", body)
|
||||
assert.NotEmpty(t, gjson.Get(body, "session.id").String(), "%s", body)
|
||||
assert.NotContains(t, gjson.Get(body, "continue_with").Raw, string(flow.ContinueWithActionRedirectBrowserToString), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
body := expectSuccessfulRegistration(t, false, true, nil, func(v url.Values) {
|
||||
v.Set("traits.username", "registration-identifier-8-spa")
|
||||
v.Set("password", x.NewUUID().String())
|
||||
v.Set("traits.foobar", "bar")
|
||||
})
|
||||
assert.Equal(t, `registration-identifier-8-spa`, gjson.Get(body, "identity.traits.username").String(), "%s", body)
|
||||
assert.Empty(t, gjson.Get(body, "session_token").String(), "%s", body)
|
||||
assert.NotEmpty(t, gjson.Get(body, "session.id").String(), "%s", body)
|
||||
assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(body, "continue_with.0.action").String(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
body := expectSuccessfulRegistration(t, false, false, nil, func(v url.Values) {
|
||||
v.Set("traits.username", "registration-identifier-8-browser")
|
||||
v.Set("password", x.NewUUID().String())
|
||||
v.Set("traits.foobar", "bar")
|
||||
})
|
||||
assert.Equal(t, `registration-identifier-8-browser`, gjson.Get(body, "identity.traits.username").String(), "%s", body)
|
||||
assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%s", body)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=should not set up a session if hook is not configured", func(t *testing.T) {
|
||||
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json")
|
||||
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil)
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
body := expectNoRegistration(t, true, false, nil, func(v url.Values) {
|
||||
v.Set("traits.username", "registration-identifier-8-api-nosession")
|
||||
v.Set("password", x.NewUUID().String())
|
||||
v.Set("traits.foobar", "bar")
|
||||
})
|
||||
assert.Equal(t, `registration-identifier-8-api-nosession`, gjson.Get(body, "identity.traits.username").String(), "%s", body)
|
||||
assert.Empty(t, gjson.Get(body, "session_token").String(), "%s", body)
|
||||
assert.Empty(t, gjson.Get(body, "session.id").String(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
expectNoRegistration(t, false, true, nil, func(v url.Values) {
|
||||
v.Set("traits.username", "registration-identifier-8-spa-nosession")
|
||||
v.Set("password", x.NewUUID().String())
|
||||
v.Set("traits.foobar", "bar")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
expectNoRegistration(t, false, false, nil, func(v url.Values) {
|
||||
v.Set("traits.username", "registration-identifier-8-browser-nosession")
|
||||
v.Set("password", x.NewUUID().String())
|
||||
v.Set("traits.foobar", "bar")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=should fail to register the same user again", func(t *testing.T) {
|
||||
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json")
|
||||
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), []config.SelfServiceHook{{Name: "session"}})
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil)
|
||||
})
|
||||
|
||||
applyTransform := func(values, transform func(v url.Values)) func(v url.Values) {
|
||||
return func(v url.Values) {
|
||||
values(v)
|
||||
transform(v)
|
||||
}
|
||||
}
|
||||
|
||||
// test duplicate registration on all client types, where the values can be transformed before
|
||||
// they are sent the second time
|
||||
testWithTransform := func(t *testing.T, suffix string, transform func(v url.Values)) {
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
values := func(v url.Values) {
|
||||
v.Set("traits.username", "registration-identifier-8-api-duplicate-"+suffix)
|
||||
v.Set("password", x.NewUUID().String())
|
||||
v.Set("traits.foobar", "bar")
|
||||
}
|
||||
|
||||
_ = expectSuccessfulRegistration(t, true, false, apiClient, values)
|
||||
body := testhelpers.SubmitRegistrationForm(t, true, apiClient, publicTS,
|
||||
applyTransform(values, transform), false, http.StatusBadRequest,
|
||||
publicTS.URL+registration.RouteSubmitFlow)
|
||||
assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "You tried signing in with registration-identifier-8-api-duplicate-"+suffix+" which is already in use by another account. You can sign in using your password.", "%s", body)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
values := func(v url.Values) {
|
||||
v.Set("traits.username", "registration-identifier-8-spa-duplicate-"+suffix)
|
||||
v.Set("password", x.NewUUID().String())
|
||||
v.Set("traits.foobar", "bar")
|
||||
}
|
||||
|
||||
_ = expectSuccessfulRegistration(t, false, true, nil, values)
|
||||
body := registrationhelpers.ExpectValidationError(t.Context(), t, publicTS, conf, "spa", applyTransform(values, transform))
|
||||
assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "You tried signing in with registration-identifier-8-spa-duplicate-"+suffix+" which is already in use by another account. You can sign in using your password.", "%s", body)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
values := func(v url.Values) {
|
||||
v.Set("traits.username", "registration-identifier-8-browser-duplicate-"+suffix)
|
||||
v.Set("password", x.NewUUID().String())
|
||||
v.Set("traits.foobar", "bar")
|
||||
}
|
||||
|
||||
_ = expectSuccessfulRegistration(t, false, false, nil, values)
|
||||
body := registrationhelpers.ExpectValidationError(t.Context(), t, publicTS, conf, "browser", applyTransform(values, transform))
|
||||
assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "You tried signing in with registration-identifier-8-browser-duplicate-"+suffix+" which is already in use by another account. You can sign in using your password.", "%s", body)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("case=identical input", func(t *testing.T) {
|
||||
testWithTransform(t, "identical", func(v url.Values) {
|
||||
// base case
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=different capitalization", func(t *testing.T) {
|
||||
testWithTransform(t, "caps", func(v url.Values) {
|
||||
v.Set("traits.username", strings.ToUpper(v.Get("traits.username")))
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=leading whitespace", func(t *testing.T) {
|
||||
testWithTransform(t, "leading", func(v url.Values) {
|
||||
v.Set("traits.username", " "+v.Get("traits.username"))
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=trailing whitespace", func(t *testing.T) {
|
||||
testWithTransform(t, "trailing", func(v url.Values) {
|
||||
v.Set("traits.username", v.Get("traits.username")+" ")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=should return correct error ids from validation failures", func(t *testing.T) {
|
||||
test := func(t *testing.T, constraint string, setValues func(url.Values), expectedId text.ID, expectedMesage string) {
|
||||
template := `{
|
||||
"$id": "https://example.com/person.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"foobar": {
|
||||
%s
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"foobar",
|
||||
"username"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}`
|
||||
|
||||
testhelpers.SetDefaultIdentitySchemaFromRaw(conf, []byte(fmt.Sprintf(template, constraint)))
|
||||
|
||||
browserClient := testhelpers.NewClientWithCookies(t)
|
||||
f := testhelpers.InitializeRegistrationFlowViaBrowser(t, browserClient, publicTS, false, false, false)
|
||||
c := f.Ui
|
||||
|
||||
values := testhelpers.SDKFormFieldsToURLValues(c.Nodes)
|
||||
setValues(values)
|
||||
values.Set("traits.username", "registration-identifier-9")
|
||||
values.Set("password", x.NewUUID().String())
|
||||
actual, _ := testhelpers.RegistrationMakeRequest(t, false, false, f, browserClient, values.Encode())
|
||||
|
||||
assert.NotEmpty(t, gjson.Get(actual, "id").String(), "%s", actual)
|
||||
assert.Contains(t, gjson.Get(actual, "ui.action").String(), publicTS.URL+registration.RouteSubmitFlow, "%s", actual)
|
||||
registrationhelpers.CheckFormContent(t, []byte(actual), "password", "csrf_token", "traits.username")
|
||||
assert.EqualValues(t, "registration-identifier-9", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).attributes.value").String(), "%s", actual)
|
||||
assert.Empty(t, gjson.Get(actual, "ui.nodes.messages").Array())
|
||||
assert.Len(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).messages").Array(), 0)
|
||||
assert.Equal(t, int64(expectedId), gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).messages.0.id").Int())
|
||||
assert.Equal(t, expectedMesage, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).messages.0.text").String())
|
||||
assert.Equal(t, "error", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).messages.0.type").String())
|
||||
}
|
||||
|
||||
const key = "traits.foobar"
|
||||
t.Run("case=string violating minLength", func(t *testing.T) {
|
||||
test(t, `"type": "string", "minLength": 5`, func(v url.Values) { v.Set(key, "bar") }, text.ErrorValidationMinLength, "length must be >= 5, but got 3")
|
||||
})
|
||||
|
||||
t.Run("case=string violating maxLength", func(t *testing.T) {
|
||||
test(t, `"type": "string", "maxLength": 5`, func(v url.Values) { v.Set(key, "qwerty") }, text.ErrorValidationMaxLength, "length must be <= 5, but got 6")
|
||||
})
|
||||
|
||||
t.Run("case=string violating pattern", func(t *testing.T) {
|
||||
test(t, `"type": "string", "pattern": "^[a-z]*$"`, func(v url.Values) { v.Set(key, "FUBAR") }, text.ErrorValidationInvalidFormat, "does not match pattern \"^[a-z]*$\"")
|
||||
})
|
||||
|
||||
t.Run("case=number violating minimum", func(t *testing.T) {
|
||||
test(t, `"type": "number", "minimum": 5`, func(v url.Values) { v.Set(key, "3") }, text.ErrorValidationMinimum, "must be >= 5 but found 3")
|
||||
})
|
||||
|
||||
t.Run("case=number violating exclusiveMinimum", func(t *testing.T) {
|
||||
test(t, `"type": "number", "exclusiveMinimum": 5`, func(v url.Values) { v.Set(key, "5") }, text.ErrorValidationExclusiveMinimum, "must be > 5 but found 5")
|
||||
})
|
||||
|
||||
t.Run("case=number violating maximum", func(t *testing.T) {
|
||||
test(t, `"type": "number", "maximum": 5`, func(v url.Values) { v.Set(key, "6") }, text.ErrorValidationMaximum, "must be <= 5 but found 6")
|
||||
})
|
||||
|
||||
t.Run("case=number violating exclusiveMaximum", func(t *testing.T) {
|
||||
test(t, `"type": "number", "exclusiveMaximum": 5`, func(v url.Values) { v.Set(key, "5") }, text.ErrorValidationExclusiveMaximum, "must be < 5 but found 5")
|
||||
})
|
||||
|
||||
t.Run("case=number violating multipleOf", func(t *testing.T) {
|
||||
test(t, `"type": "number", "multipleOf": 3`, func(v url.Values) { v.Set(key, "7") }, text.ErrorValidationMultipleOf, "7 not multipleOf 3")
|
||||
})
|
||||
|
||||
t.Run("case=array violating maxItems", func(t *testing.T) {
|
||||
test(t, `"type": "array", "items": { "type": "string" }, "maxItems": 3`, func(v url.Values) { v.Add(key, "a"); v.Add(key, "b"); v.Add(key, "c"); v.Add(key, "d") }, text.ErrorValidationMaxItems, "maximum 3 items allowed, but found 4 items")
|
||||
})
|
||||
|
||||
t.Run("case=array violating minItems", func(t *testing.T) {
|
||||
test(t, `"type": "array", "items": { "type": "string" }, "minItems": 3`, func(v url.Values) { v.Add(key, "a"); v.Add(key, "b") }, text.ErrorValidationMinItems, "minimum 3 items allowed, but found 2 items")
|
||||
})
|
||||
|
||||
t.Run("case=array violating uniqueItems", func(t *testing.T) {
|
||||
test(t, `"type": "array", "items": { "type": "string" }, "uniqueItems": true`, func(v url.Values) { v.Add(key, "abc"); v.Add(key, "XYZ"); v.Add(key, "abc") }, text.ErrorValidationUniqueItems, "items at index 0 and 2 are equal")
|
||||
})
|
||||
|
||||
t.Run("case=wrong type", func(t *testing.T) {
|
||||
test(t, `"type": "number"`, func(v url.Values) { v.Set(key, "blabla") }, text.ErrorValidationWrongType, "expected number, but got string")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=should return an error because not passing validation and reset previous errors and values", func(t *testing.T) {
|
||||
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json")
|
||||
|
||||
check := func(t *testing.T, actual string) {
|
||||
assert.NotEmpty(t, gjson.Get(actual, "id").String(), "%s", actual)
|
||||
assert.Contains(t, gjson.Get(actual, "ui.action").String(), publicTS.URL+registration.RouteSubmitFlow, "%s", actual)
|
||||
registrationhelpers.CheckFormContent(t, []byte(actual), "password", "csrf_token", "traits.username")
|
||||
}
|
||||
|
||||
checkFirst := func(t *testing.T, actual string) {
|
||||
check(t, actual)
|
||||
assert.Contains(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).messages.0").String(), `Property username is missing`, "%s", actual)
|
||||
}
|
||||
|
||||
checkSecond := func(t *testing.T, actual string) {
|
||||
check(t, actual)
|
||||
assert.EqualValues(t, "registration-identifier-9", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).attributes.value").String(), "%s", actual)
|
||||
assert.Empty(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).messages").Array())
|
||||
assert.Empty(t, gjson.Get(actual, "ui.nodes.messages").Array())
|
||||
assert.Contains(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).messages.0").String(), `Property foobar is missing`, "%s", actual)
|
||||
}
|
||||
|
||||
valuesFirst := func(v url.Values) url.Values {
|
||||
v.Del("traits.username")
|
||||
v.Set("password", x.NewUUID().String())
|
||||
v.Set("traits.foobar", "bar")
|
||||
return v
|
||||
}
|
||||
|
||||
valuesSecond := func(v url.Values) url.Values {
|
||||
v.Set("traits.username", "registration-identifier-9")
|
||||
v.Set("password", x.NewUUID().String())
|
||||
v.Del("traits.foobar")
|
||||
return v
|
||||
}
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
f := testhelpers.InitializeRegistrationFlowViaAPI(t, apiClient, publicTS)
|
||||
c := f.Ui
|
||||
|
||||
actual, _ := testhelpers.RegistrationMakeRequest(t, true, false, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, valuesFirst(testhelpers.SDKFormFieldsToURLValues(c.Nodes))))
|
||||
checkFirst(t, actual)
|
||||
actual, _ = testhelpers.RegistrationMakeRequest(t, true, false, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, valuesSecond(testhelpers.SDKFormFieldsToURLValues(c.Nodes))))
|
||||
checkSecond(t, actual)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
browserClient := testhelpers.NewClientWithCookies(t)
|
||||
f := testhelpers.InitializeRegistrationFlowViaBrowser(t, browserClient, publicTS, true, false, false)
|
||||
c := f.Ui
|
||||
|
||||
actual, _ := testhelpers.RegistrationMakeRequest(t, false, true, f, browserClient, testhelpers.EncodeFormAsJSON(t, false, valuesFirst(testhelpers.SDKFormFieldsToURLValues(c.Nodes))))
|
||||
checkFirst(t, actual)
|
||||
actual, _ = testhelpers.RegistrationMakeRequest(t, false, true, f, browserClient, testhelpers.EncodeFormAsJSON(t, false, valuesSecond(testhelpers.SDKFormFieldsToURLValues(c.Nodes))))
|
||||
checkSecond(t, actual)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
browserClient := testhelpers.NewClientWithCookies(t)
|
||||
f := testhelpers.InitializeRegistrationFlowViaBrowser(t, browserClient, publicTS, false, false, false)
|
||||
c := f.Ui
|
||||
|
||||
actual, _ := testhelpers.RegistrationMakeRequest(t, false, false, f, browserClient, valuesFirst(testhelpers.SDKFormFieldsToURLValues(c.Nodes)).Encode())
|
||||
checkFirst(t, actual)
|
||||
actual, _ = testhelpers.RegistrationMakeRequest(t, false, false, f, browserClient, valuesSecond(testhelpers.SDKFormFieldsToURLValues(c.Nodes)).Encode())
|
||||
checkSecond(t, actual)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=should work even if password is just numbers", func(t *testing.T) {
|
||||
testhelpers.SetDefaultIdentitySchema(conf, "file://stub/registration.schema.json")
|
||||
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), []config.SelfServiceHook{{Name: "session"}})
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil)
|
||||
})
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
actual := expectSuccessfulRegistration(t, true, false, nil, func(v url.Values) {
|
||||
v.Set("traits.username", "registration-identifier-10-api")
|
||||
v.Set("password", x.NewUUID().String())
|
||||
v.Set("traits.foobar", "bar")
|
||||
})
|
||||
assert.Equal(t, `registration-identifier-10-api`, gjson.Get(actual, "identity.traits.username").String(), "%s", actual)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
actual := expectSuccessfulRegistration(t, false, false, nil, func(v url.Values) {
|
||||
v.Set("traits.username", "registration-identifier-10-spa")
|
||||
v.Set("password", x.NewUUID().String())
|
||||
v.Set("traits.foobar", "bar")
|
||||
})
|
||||
assert.Equal(t, `registration-identifier-10-spa`, gjson.Get(actual, "identity.traits.username").String(), "%s", actual)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
actual := expectSuccessfulRegistration(t, false, false, nil, func(v url.Values) {
|
||||
v.Set("traits.username", "registration-identifier-10-browser")
|
||||
v.Set("password", x.NewUUID().String())
|
||||
v.Set("traits.foobar", "bar")
|
||||
})
|
||||
assert.Equal(t, `registration-identifier-10-browser`, gjson.Get(actual, "identity.traits.username").String(), "%s", actual)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=should fail if no identifier was set in the schema", func(t *testing.T) {
|
||||
testhelpers.SetDefaultIdentitySchema(conf, "file://stub/missing-identifier.schema.json")
|
||||
|
||||
for _, f := range []string{"spa", "api", "browser"} {
|
||||
t.Run("type="+f, func(t *testing.T) {
|
||||
actual := registrationhelpers.ExpectValidationError(t.Context(), t, publicTS, conf, f, func(v url.Values) {
|
||||
v.Set("traits.email", testhelpers.RandomEmail())
|
||||
v.Set("password", x.NewUUID().String())
|
||||
v.Set("method", "password")
|
||||
})
|
||||
assert.Equal(t, text.NewErrorValidationIdentifierMissing().Text, gjson.Get(actual, "ui.messages.0.text").String(), "%s", actual)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case=should work with regular JSON", func(t *testing.T) {
|
||||
testhelpers.SetDefaultIdentitySchema(conf, "file://stub/registration.schema.json")
|
||||
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), []config.SelfServiceHook{{Name: "session"}})
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil)
|
||||
})
|
||||
|
||||
hc := testhelpers.NewClientWithCookies(t)
|
||||
hc.Transport = testhelpers.NewTransportWithLogger(hc.Transport, t)
|
||||
payload := testhelpers.InitializeRegistrationFlowViaBrowser(t, hc, publicTS, false, false, false)
|
||||
values := testhelpers.SDKFormFieldsToURLValues(payload.Ui.Nodes)
|
||||
time.Sleep(time.Millisecond) // add a bit of delay to allow `1ns` to time out.
|
||||
|
||||
username := x.NewUUID()
|
||||
actual, res := testhelpers.RegistrationMakeRequest(t, true, false, payload, hc, fmt.Sprintf(`{
|
||||
"method": "password",
|
||||
"csrf_token": "%s",
|
||||
"password": "%s",
|
||||
"traits": {
|
||||
"foobar": "bar",
|
||||
"username": "%s"
|
||||
}
|
||||
}`, values.Get("csrf_token"), x.NewUUID(), username))
|
||||
assert.EqualValues(t, http.StatusOK, res.StatusCode, assertx.PrettifyJSONPayload(t, actual))
|
||||
assert.Equal(t, username.String(), gjson.Get(actual, "identity.traits.username").String(), "%s", actual)
|
||||
})
|
||||
|
||||
t.Run("case=should choose the correct identity schema", func(t *testing.T) {
|
||||
conf.MustSet(t.Context(), config.ViperKeyDefaultIdentitySchemaID, "advanced-user")
|
||||
conf.MustSet(t.Context(), config.ViperKeyIdentitySchemas, config.Schemas{
|
||||
{ID: "does-not-exist", URL: "file://./stub/not-exists.schema.json"},
|
||||
{ID: "advanced-user", URL: "file://./stub/registration.secondary.schema.json"},
|
||||
})
|
||||
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil)
|
||||
|
||||
username := "registration-custom-schema"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
isAPI bool
|
||||
isSPA bool
|
||||
}{
|
||||
{
|
||||
name: "api",
|
||||
isAPI: true,
|
||||
}, {
|
||||
name: "spa",
|
||||
isSPA: true,
|
||||
}, {
|
||||
name: "browser",
|
||||
},
|
||||
} {
|
||||
t.Run("type="+tc.name, func(t *testing.T) {
|
||||
body := expectNoRegistration(t, tc.isAPI, tc.isSPA, nil, func(v url.Values) {
|
||||
v.Set("traits.username", username+"-"+tc.name)
|
||||
v.Set("password", x.NewUUID().String())
|
||||
v.Set("traits.baz", "bar")
|
||||
})
|
||||
|
||||
if tc.isAPI || tc.isSPA { // body is empty for browser flow
|
||||
assert.Equal(t, username+"-"+tc.name, gjson.Get(body, "identity.traits.username").String(), "%s", body)
|
||||
assert.Empty(t, gjson.Get(body, "session_token").String(), "%s", body)
|
||||
assert.Empty(t, gjson.Get(body, "session.id").String(), "%s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case=multi-schema select", func(t *testing.T) {
|
||||
conf.MustSet(t.Context(), config.ViperKeyIdentitySchemas, config.Schemas{
|
||||
{ID: "username", URL: "file://stub/sort.schema.json", SelfserviceSelectable: true},
|
||||
{ID: "email", URL: "file://stub/email.schema.json", SelfserviceSelectable: true},
|
||||
{ID: "phone", URL: "file://stub/phone.schema.json"},
|
||||
{ID: "not-allowed", URL: "file://stub/login.schema.json"},
|
||||
})
|
||||
// We set default to phone and select email in InitializeRegistrationFlowViaBrowser
|
||||
conf.MustSet(t.Context(), config.ViperKeyDefaultIdentitySchemaID, "phone")
|
||||
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceRegistrationAfter+"."+config.DefaultBrowserReturnURL, "https://www.ory.com")
|
||||
|
||||
browserClient := testhelpers.NewClientWithCookies(t)
|
||||
|
||||
expected := container.Container{
|
||||
Method: "POST",
|
||||
Nodes: node.Nodes{
|
||||
node.NewCSRFNode(nosurfx.FakeCSRFToken),
|
||||
node.NewInputField("traits.email", nil, node.DefaultGroup, node.InputAttributeTypeEmail, node.WithRequiredInputAttribute, node.WithInputAttributes(func(a *node.InputAttributes) {
|
||||
a.Autocomplete = node.InputAttributeAutocompleteEmail
|
||||
})).WithMetaLabel(text.NewInfoNodeLabelGenerated("E-Mail", "traits.email")),
|
||||
node.NewInputField("password", nil, node.PasswordGroup, node.InputAttributeTypePassword, node.WithRequiredInputAttribute, node.WithInputAttributes(func(a *node.InputAttributes) {
|
||||
a.Autocomplete = node.InputAttributeAutocompleteNewPassword
|
||||
})).WithMetaLabel(text.NewInfoNodeInputPassword()),
|
||||
node.NewInputField("method", "password", node.PasswordGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoRegistration()),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
isAPI bool
|
||||
isSPA bool
|
||||
}{
|
||||
{
|
||||
name: "api",
|
||||
isAPI: true,
|
||||
},
|
||||
{
|
||||
name: "spa",
|
||||
isSPA: true,
|
||||
},
|
||||
{
|
||||
name: "browser",
|
||||
},
|
||||
} {
|
||||
t.Run("type="+tc.name+" registration success", func(t *testing.T) {
|
||||
var f *kratos.RegistrationFlow
|
||||
var hc *http.Client
|
||||
var payload string
|
||||
if tc.isAPI {
|
||||
f = testhelpers.InitializeRegistrationFlowViaAPICtx(t.Context(), t, apiClient, publicTS, testhelpers.InitFlowWithIdentitySchema("email"))
|
||||
hc = apiClient
|
||||
} else {
|
||||
f = testhelpers.InitializeRegistrationFlowViaBrowserCtx(t.Context(), t, browserClient, publicTS, tc.isSPA, false, false, testhelpers.InitFlowWithIdentitySchema("email"))
|
||||
hc = browserClient
|
||||
}
|
||||
|
||||
expected.Action = conf.SelfPublicURL(t.Context()).String() + registration.RouteSubmitFlow + "?flow=" + f.Id
|
||||
assertx.EqualAsJSON(t, expected, f.Ui)
|
||||
|
||||
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
||||
values.Set("traits.email", testhelpers.RandomEmail())
|
||||
values.Set("password", x.NewUUID().String())
|
||||
|
||||
var expectedURL string
|
||||
if tc.isAPI {
|
||||
payload = testhelpers.EncodeFormAsJSON(t, true, values)
|
||||
} else {
|
||||
payload = values.Encode()
|
||||
}
|
||||
if tc.name == "browser" {
|
||||
expectedURL = redirTS.URL
|
||||
} else {
|
||||
expectedURL = conf.SelfPublicURL(t.Context()).String() + login.RouteSubmitFlow
|
||||
}
|
||||
|
||||
actual, resp := testhelpers.RegistrationMakeRequest(t, tc.isAPI, tc.isSPA, f, hc, payload)
|
||||
require.EqualValues(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
if tc.isAPI {
|
||||
assert.NotEmpty(t, gjson.Get(actual, "identity.id").String(), "%s", actual)
|
||||
assert.Equal(t, gjson.Get(actual, "identity.traits.email").String(), values.Get("traits.email"), "%s", actual)
|
||||
assert.Equal(t, gjson.Get(actual, "identity.schema_id").String(), "email", "%s", actual)
|
||||
}
|
||||
|
||||
identity, _, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(t.Context(), identity.CredentialsTypePassword, values.Get("traits.email"))
|
||||
require.NoError(t, err, sqlcon.ErrNoRows())
|
||||
|
||||
assert.NotEmpty(t, identity.ID)
|
||||
assert.Equalf(t, values.Get("traits.email"), gjson.GetBytes(identity.Traits, "email").String(), "%s", identity.Traits)
|
||||
assert.Equal(t, "email", identity.SchemaID)
|
||||
|
||||
// login
|
||||
loginValues := func(v url.Values) {
|
||||
v.Set("identifier", values.Get("traits.email"))
|
||||
v.Set("password", values.Get("password"))
|
||||
}
|
||||
|
||||
body := testhelpers.SubmitLoginForm(t, tc.isAPI, nil, publicTS, loginValues,
|
||||
tc.isSPA, false, http.StatusOK, expectedURL, testhelpers.InitFlowWithIdentitySchema("email"))
|
||||
|
||||
if tc.name == "browser" {
|
||||
assert.Equalf(t, identity.ID.String(), gjson.Get(body, "identity.id").String(), "%s", body)
|
||||
} else {
|
||||
assert.Equalf(t, identity.ID.String(), gjson.Get(body, "session.identity.id").String(), "%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("type="+tc.name+" registration fail due to invalid form data", func(t *testing.T) {
|
||||
var f *kratos.RegistrationFlow
|
||||
var hc *http.Client
|
||||
var payload string
|
||||
if tc.isAPI {
|
||||
f = testhelpers.InitializeRegistrationFlowViaAPICtx(t.Context(), t, apiClient, publicTS, testhelpers.InitFlowWithIdentitySchema("email"))
|
||||
hc = apiClient
|
||||
} else {
|
||||
f = testhelpers.InitializeRegistrationFlowViaBrowserCtx(t.Context(), t, browserClient, publicTS, tc.isSPA, false, false, testhelpers.InitFlowWithIdentitySchema("email"))
|
||||
hc = browserClient
|
||||
}
|
||||
|
||||
expected.Action = conf.SelfPublicURL(t.Context()).String() + registration.RouteSubmitFlow + "?flow=" + f.Id
|
||||
assertx.EqualAsJSON(t, expected, f.Ui)
|
||||
|
||||
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
||||
values.Set("traits.email", "invalidemail")
|
||||
values.Set("password", x.NewUUID().String())
|
||||
|
||||
if tc.isAPI {
|
||||
payload = testhelpers.EncodeFormAsJSON(t, true, values)
|
||||
} else {
|
||||
payload = values.Encode()
|
||||
}
|
||||
|
||||
actual, resp := testhelpers.RegistrationMakeRequest(t, tc.isAPI, tc.isSPA, f, hc, payload)
|
||||
if tc.name == "browser" {
|
||||
require.EqualValues(t, http.StatusOK, resp.StatusCode)
|
||||
} else {
|
||||
require.EqualValues(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
assert.EqualValuesf(t, 4000040, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.email).messages.0.id").Int(), "%s", actual)
|
||||
assert.Equalf(t, "Enter a valid email address", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.email).messages.0.text").String(), "%s", actual)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("type=browser schema=default", func(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(t.Context(), config.ViperKeyDefaultIdentitySchemaID, "phone")
|
||||
})
|
||||
// We set default to email
|
||||
conf.MustSet(t.Context(), config.ViperKeyDefaultIdentitySchemaID, "email")
|
||||
|
||||
f := testhelpers.InitializeRegistrationFlowViaBrowserCtx(t.Context(), t, browserClient, publicTS, false, false, false)
|
||||
|
||||
expected.Action = conf.SelfPublicURL(t.Context()).String() + registration.RouteSubmitFlow + "?flow=" + f.Id
|
||||
assertx.EqualAsJSON(t, expected, f.Ui)
|
||||
})
|
||||
|
||||
t.Run("type=api schema=default", func(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(t.Context(), config.ViperKeyDefaultIdentitySchemaID, "phone")
|
||||
})
|
||||
// We set default to email
|
||||
conf.MustSet(t.Context(), config.ViperKeyDefaultIdentitySchemaID, "email")
|
||||
|
||||
f := testhelpers.InitializeRegistrationFlowViaAPICtx(t.Context(), t, apiClient, publicTS)
|
||||
|
||||
expected.Action = conf.SelfPublicURL(t.Context()).String() + registration.RouteSubmitFlow + "?flow=" + f.Id
|
||||
assertx.EqualAsJSON(t, expected, f.Ui)
|
||||
|
||||
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
||||
values.Set("traits.email", testhelpers.RandomEmail())
|
||||
values.Set("password", x.NewUUID().String())
|
||||
|
||||
actual, _ := testhelpers.RegistrationMakeRequest(t, true, false, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, values))
|
||||
|
||||
assert.NotEmptyf(t, gjson.Get(actual, "identity.id").String(), "%s", actual)
|
||||
assert.Containsf(t, gjson.Get(actual, "identity.traits.email").String(), values.Get("email"), "%s", actual)
|
||||
assert.Containsf(t, gjson.Get(actual, "identity.schema_id").String(), "email", "%s", actual)
|
||||
})
|
||||
|
||||
t.Run("type=browser schema=does-not-exist", func(t *testing.T) {
|
||||
testhelpers.InitializeRegistrationFlowViaBrowserCtx(t.Context(), t, browserClient, publicTS, false, false, true, testhelpers.InitFlowWithIdentitySchema("does-not-exist"))
|
||||
|
||||
testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, publicTS, false, false, false, true, testhelpers.InitFlowWithIdentitySchema("does-not-exist"))
|
||||
})
|
||||
|
||||
t.Run("type=api schema=does-not-exist", func(t *testing.T) {
|
||||
testhelpers.InitializeRegistrationFlowViaAPIExpectError(t, apiClient, publicTS, testhelpers.InitFlowWithIdentitySchema("does-not-exist"))
|
||||
|
||||
testhelpers.InitializeLoginFlowViaAPIExpectError(t.Context(), t, apiClient, publicTS, false, testhelpers.InitFlowWithIdentitySchema("does-not-exist"))
|
||||
})
|
||||
|
||||
t.Run("type=browser schema=not-allowed", func(t *testing.T) {
|
||||
testhelpers.InitializeRegistrationFlowViaBrowserCtx(t.Context(), t, browserClient, publicTS, false, false, true, testhelpers.InitFlowWithIdentitySchema("not-allowed"))
|
||||
|
||||
testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, publicTS, false, false, false, true, testhelpers.InitFlowWithIdentitySchema("not-allowed"))
|
||||
})
|
||||
|
||||
t.Run("type=api schema=not-allowed", func(t *testing.T) {
|
||||
testhelpers.InitializeRegistrationFlowViaAPIExpectError(t, apiClient, publicTS, testhelpers.InitFlowWithIdentitySchema("not-allowed"))
|
||||
|
||||
testhelpers.InitializeLoginFlowViaAPIExpectError(t.Context(), t, apiClient, publicTS, false, testhelpers.InitFlowWithIdentitySchema("not-allowed"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("method=PopulateSignUpMethod", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t,
|
||||
configx.WithValues(map[string]any{
|
||||
config.ViperKeyPublicBaseURL: "https://foo/",
|
||||
config.ViperKeySelfServiceRegistrationEnableLegacyOneStep: true,
|
||||
config.ViperKeySelfServiceStrategyConfig + "." + string(identity.CredentialsTypePassword) + ".enabled": true,
|
||||
}),
|
||||
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://stub/sort.schema.json")),
|
||||
)
|
||||
|
||||
publicTS, _ := testhelpers.NewKratosServer(t, reg)
|
||||
_ = testhelpers.NewRegistrationUIFlowEchoServer(t, reg)
|
||||
|
||||
browserClient := testhelpers.NewClientWithCookies(t)
|
||||
f := testhelpers.InitializeRegistrationFlowViaBrowserCtx(t.Context(), t, browserClient, publicTS, false, false, false)
|
||||
|
||||
assertx.EqualAsJSON(t, container.Container{
|
||||
Action: conf.SelfPublicURL(t.Context()).String() + registration.RouteSubmitFlow + "?flow=" + f.Id,
|
||||
Method: "POST",
|
||||
Nodes: node.Nodes{
|
||||
node.NewCSRFNode(nosurfx.FakeCSRFToken),
|
||||
node.NewInputField("traits.username", nil, node.DefaultGroup, node.InputAttributeTypeText),
|
||||
node.NewInputField("password", nil, node.PasswordGroup, node.InputAttributeTypePassword, node.WithRequiredInputAttribute, node.WithInputAttributes(func(a *node.InputAttributes) {
|
||||
a.Autocomplete = node.InputAttributeAutocompleteNewPassword
|
||||
})).WithMetaLabel(text.NewInfoNodeInputPassword()),
|
||||
node.NewInputField("traits.bar", nil, node.DefaultGroup, node.InputAttributeTypeText),
|
||||
node.NewInputField("method", "password", node.PasswordGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoRegistration()),
|
||||
},
|
||||
}, f.Ui)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPopulateRegistrationMethod(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t,
|
||||
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://stub/identity.schema.json")),
|
||||
)
|
||||
|
||||
s, err := reg.AllRegistrationStrategies().Strategy(identity.CredentialsTypePassword)
|
||||
require.NoError(t, err)
|
||||
fh, ok := s.(registration.FormHydrator)
|
||||
require.True(t, ok)
|
||||
|
||||
toSnapshot := func(t *testing.T, f node.Nodes) {
|
||||
t.Helper()
|
||||
// The CSRF token has a unique value that messes with the snapshot - ignore it.
|
||||
f.ResetNodes("csrf_token")
|
||||
snapshotx.SnapshotT(t, f, snapshotx.ExceptNestedKeys("nonce", "src"))
|
||||
}
|
||||
|
||||
newFlow := func(ctx context.Context, t *testing.T) (*http.Request, *registration.Flow) {
|
||||
r := httptest.NewRequest("GET", "/self-service/registration/browser", nil)
|
||||
r = r.WithContext(ctx)
|
||||
t.Helper()
|
||||
f, err := registration.NewFlow(reg, r, flow.TypeBrowser)
|
||||
f.UI.Nodes = make(node.Nodes, 0)
|
||||
require.NoError(t, err)
|
||||
return r, f
|
||||
}
|
||||
|
||||
t.Run("method=PopulateRegistrationMethod", func(t *testing.T) {
|
||||
r, f := newFlow(t.Context(), t)
|
||||
require.NoError(t, fh.PopulateRegistrationMethod(r, f))
|
||||
toSnapshot(t, f.UI.Nodes)
|
||||
})
|
||||
|
||||
t.Run("method=PopulateRegistrationMethodProfile", func(t *testing.T) {
|
||||
r, f := newFlow(t.Context(), t)
|
||||
require.NoError(t, fh.PopulateRegistrationMethodProfile(r, f))
|
||||
toSnapshot(t, f.UI.Nodes)
|
||||
})
|
||||
|
||||
t.Run("method=PopulateRegistrationMethodCredentials", func(t *testing.T) {
|
||||
r, f := newFlow(t.Context(), t)
|
||||
require.NoError(t, fh.PopulateRegistrationMethodCredentials(r, f))
|
||||
toSnapshot(t, f.UI.Nodes)
|
||||
})
|
||||
|
||||
t.Run("method=idempotency", func(t *testing.T) {
|
||||
r, f := newFlow(t.Context(), t)
|
||||
|
||||
var snapshots []node.Nodes
|
||||
|
||||
t.Run("case=1", func(t *testing.T) {
|
||||
require.NoError(t, fh.PopulateRegistrationMethodProfile(r, f))
|
||||
snapshots = append(snapshots, f.UI.Nodes)
|
||||
toSnapshot(t, f.UI.Nodes)
|
||||
})
|
||||
|
||||
t.Run("case=2", func(t *testing.T) {
|
||||
require.NoError(t, fh.PopulateRegistrationMethodCredentials(r, f))
|
||||
snapshots = append(snapshots, f.UI.Nodes)
|
||||
toSnapshot(t, f.UI.Nodes)
|
||||
})
|
||||
|
||||
t.Run("case=3", func(t *testing.T) {
|
||||
require.NoError(t, fh.PopulateRegistrationMethodProfile(r, f))
|
||||
snapshots = append(snapshots, f.UI.Nodes)
|
||||
toSnapshot(t, f.UI.Nodes)
|
||||
})
|
||||
|
||||
t.Run("case=4", func(t *testing.T) {
|
||||
require.NoError(t, fh.PopulateRegistrationMethodCredentials(r, f))
|
||||
snapshots = append(snapshots, f.UI.Nodes)
|
||||
toSnapshot(t, f.UI.Nodes)
|
||||
})
|
||||
|
||||
t.Run("case=evaluate", func(t *testing.T) {
|
||||
assertx.EqualAsJSON(t, snapshots[0], snapshots[2])
|
||||
assertx.EqualAsJSON(t, snapshots[1], snapshots[3])
|
||||
})
|
||||
})
|
||||
}
|
||||
17
selfservice/strategy/password/schema.go
Normal file
17
selfservice/strategy/password/schema.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed .schema/login.schema.json
|
||||
var loginSchema []byte
|
||||
|
||||
//go:embed .schema/registration.schema.json
|
||||
var registrationSchema []byte
|
||||
|
||||
//go:embed .schema/settings.schema.json
|
||||
var settingsSchema []byte
|
||||
242
selfservice/strategy/password/settings.go
Normal file
242
selfservice/strategy/password/settings.go
Normal file
@@ -0,0 +1,242 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/ory/x/otelx"
|
||||
|
||||
"github.com/ory/kratos/hash"
|
||||
"github.com/ory/kratos/text"
|
||||
|
||||
"github.com/ory/kratos/ui/node"
|
||||
|
||||
"github.com/ory/kratos/session"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/kratos/continuity"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/schema"
|
||||
"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.CredentialsTypePassword.String()
|
||||
}
|
||||
|
||||
// Update Settings Flow with Password Method
|
||||
//
|
||||
// swagger:model updateSettingsFlowWithPasswordMethod
|
||||
type updateSettingsFlowWithPasswordMethod struct {
|
||||
// Password is the updated password
|
||||
//
|
||||
// required: true
|
||||
Password string `json:"password"`
|
||||
|
||||
// CSRFToken is the anti-CSRF token
|
||||
CSRFToken string `json:"csrf_token"`
|
||||
|
||||
// Method
|
||||
//
|
||||
// Should be set to password when trying to update a password.
|
||||
//
|
||||
// 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 *updateSettingsFlowWithPasswordMethod) GetFlowID() uuid.UUID {
|
||||
return x.ParseUUID(p.Flow)
|
||||
}
|
||||
|
||||
func (p *updateSettingsFlowWithPasswordMethod) 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.password.Strategy.Settings")
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
var p updateSettingsFlowWithPasswordMethod
|
||||
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(ctx, w, r, ctxUpdate, p, err)
|
||||
}
|
||||
|
||||
if err := flow.MethodEnabledAndAllowedFromRequest(r, f.GetFlowName(), s.SettingsStrategyID(), s.d); err != nil {
|
||||
return ctxUpdate, s.handleSettingsError(ctx, w, r, ctxUpdate, p, err)
|
||||
}
|
||||
|
||||
if err := s.decodeSettingsFlow(r, &p); err != nil {
|
||||
return ctxUpdate, s.handleSettingsError(ctx, w, r, ctxUpdate, p, err)
|
||||
}
|
||||
|
||||
// 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(ctx, 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.HTTPKeepRequestBody(true),
|
||||
decoderx.HTTPDecoderAllowedMethods("POST", "GET"),
|
||||
decoderx.HTTPDecoderSetValidatePayloads(true),
|
||||
decoderx.HTTPDecoderJSONFollowsFormFormat(),
|
||||
)
|
||||
}
|
||||
|
||||
// Try to find a password hash in the credentials. Returns it if found, otherwise return an empty string.
|
||||
func getPasswordHashFromCredential(creds map[identity.CredentialsType]identity.Credentials) string {
|
||||
if creds == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
cred, ok := creds[identity.CredentialsTypePassword]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
var hashedPassword identity.CredentialsPassword
|
||||
if err := json.Unmarshal(cred.Config, &hashedPassword); err != nil {
|
||||
return ""
|
||||
}
|
||||
return hashedPassword.HashedPassword
|
||||
}
|
||||
|
||||
// Detect whether the new password is the same as the old password.
|
||||
// This is helpful to a user, e.g. in the case of a password leak: they want to change their password,
|
||||
// and unknowingly set the new password to be the same as the old one (that leaked). We force them to
|
||||
// set a different password in that case.
|
||||
func isNewPasswordSameAsOld(ctx context.Context, oldHashedPassword string, newPassword string) bool {
|
||||
if oldHashedPassword == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// `hash.Compare` returns `nil` on 'success' i.e. old and new are the same.
|
||||
return hash.Compare(ctx, []byte(newPassword), []byte(oldHashedPassword)) == nil
|
||||
}
|
||||
|
||||
func (s *Strategy) continueSettingsFlow(ctx context.Context, r *http.Request, ctxUpdate *settings.UpdateContext, p updateSettingsFlowWithPasswordMethod) error {
|
||||
if err := flow.MethodEnabledAndAllowed(ctx, flow.SettingsFlow, s.SettingsStrategyID(), p.Method, 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 ctxUpdate.Session.AuthenticatedAt.Add(s.d.Config().SelfServiceFlowSettingsPrivilegedSessionMaxAge(ctx)).Before(time.Now()) {
|
||||
return errors.WithStack(settings.NewFlowNeedsReAuth())
|
||||
}
|
||||
|
||||
if len(p.Password) == 0 {
|
||||
return schema.NewRequiredError("#/password", "password")
|
||||
}
|
||||
|
||||
i, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(ctx, ctxUpdate.Session.Identity.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
var newPasswordHash []byte
|
||||
// Extract an immutable value to avoid data races between goroutines.
|
||||
oldHashedPassword := getPasswordHashFromCredential(i.Credentials)
|
||||
|
||||
// Do in parallel due to limitations of the `bcrypt` library and for performance:
|
||||
// - `hash(newPassword)` (expensive).
|
||||
// - Check that the new password is not the same as the old password,
|
||||
// which internally computes `hash(newPassword)` (expensive).
|
||||
// - `validateCredentials` which may call the HaveIBeenPawned external API.
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
newPasswordHash, err = s.d.Hasher(ctx).Generate(ctx, []byte(p.Password))
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
if isNewPasswordSameAsOld(ctx, oldHashedPassword, p.Password) {
|
||||
return schema.NewPasswordPolicyViolationError("#/password", text.NewErrorValidationPasswordNewSameAsOld())
|
||||
}
|
||||
return nil
|
||||
})
|
||||
g.Go(func() error {
|
||||
// Note: this goroutine mutates `i` so careful not to share it with other goroutines!
|
||||
|
||||
// The credentials could have been modified in many ways possible. To keep it simple, we reset, and the validators
|
||||
// will populate it correctly.
|
||||
i.UpsertCredentialsConfig(s.ID(), []byte("{}"), 0)
|
||||
return s.validateCredentials(ctx, i, p.Password)
|
||||
})
|
||||
if err := g.Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
co, err := json.Marshal(&identity.CredentialsPassword{HashedPassword: string(newPasswordHash)})
|
||||
if err != nil {
|
||||
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to encode password options to JSON: %s", err))
|
||||
}
|
||||
i.UpsertCredentialsConfig(s.ID(), co, 0)
|
||||
ctxUpdate.UpdateIdentity(i)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Strategy) PopulateSettingsMethod(ctx context.Context, r *http.Request, _ *identity.Identity, f *settings.Flow) (err error) {
|
||||
_, span := s.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.password.Strategy.PopulateSettingsMethod")
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
||||
f.UI.Nodes.Upsert(NewPasswordNode("password", node.InputAttributeAutocompleteNewPassword).WithMetaLabel(text.NewInfoNodeInputPassword()))
|
||||
f.UI.Nodes.Append(node.NewInputField("method", "password", node.PasswordGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoNodeLabelSave()))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Strategy) handleSettingsError(ctx context.Context, w http.ResponseWriter, r *http.Request, ctxUpdate *settings.UpdateContext, p updateSettingsFlowWithPasswordMethod, 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 {
|
||||
if _, err := s.d.ContinuityManager().Pause(ctx, w, r, settings.ContinuityKey(s.SettingsStrategyID()), continuity.NewCookieReferenceStore(s.d.ContinuityCookieManager(ctx)), settings.ContinuityOptions(p, ctxUpdate.GetSessionIdentity())...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if ctxUpdate.Flow != nil {
|
||||
ctxUpdate.Flow.UI.ResetMessages()
|
||||
ctxUpdate.Flow.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
684
selfservice/strategy/password/settings_test.go
Normal file
684
selfservice/strategy/password/settings_test.go
Normal file
@@ -0,0 +1,684 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/client-go"
|
||||
"github.com/ory/kratos/corpx"
|
||||
"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/settingshelpers"
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/selfservice/flow/settings"
|
||||
"github.com/ory/kratos/text"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
"github.com/ory/x/assertx"
|
||||
"github.com/ory/x/httpx"
|
||||
"github.com/ory/x/ioutilx"
|
||||
"github.com/ory/x/randx"
|
||||
)
|
||||
|
||||
func init() {
|
||||
corpx.RegisterFakes()
|
||||
}
|
||||
|
||||
func newIdentityWithPassword(email string) *identity.Identity {
|
||||
return &identity.Identity{
|
||||
ID: x.NewUUID(),
|
||||
NID: x.NewUUID(),
|
||||
Credentials: map[identity.CredentialsType]identity.Credentials{
|
||||
"password": {
|
||||
Type: "password",
|
||||
Identifiers: []string{email},
|
||||
Config: []byte(`{"hashed_password":"foo"}`),
|
||||
},
|
||||
},
|
||||
State: identity.StateActive,
|
||||
Traits: identity.Traits(`{"email":"` + email + `"}`),
|
||||
SchemaID: config.DefaultIdentityTraitsSchemaID,
|
||||
}
|
||||
}
|
||||
|
||||
func newEmptyIdentity() *identity.Identity {
|
||||
return &identity.Identity{
|
||||
ID: x.NewUUID(),
|
||||
NID: x.NewUUID(),
|
||||
State: identity.StateActive,
|
||||
Traits: identity.Traits(`{}`),
|
||||
SchemaID: config.DefaultIdentityTraitsSchemaID,
|
||||
}
|
||||
}
|
||||
|
||||
func newIdentityWithoutCredentials(email string) *identity.Identity {
|
||||
return &identity.Identity{
|
||||
ID: x.NewUUID(),
|
||||
NID: x.NewUUID(),
|
||||
State: identity.StateActive,
|
||||
Traits: identity.Traits(`{"email":"` + email + `"}`),
|
||||
SchemaID: config.DefaultIdentityTraitsSchemaID,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t,
|
||||
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/profile.schema.json")),
|
||||
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypePassword, true)),
|
||||
configx.WithValues(testhelpers.MethodEnableConfig(settings.StrategyProfile, true)),
|
||||
configx.WithValue(config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, "1m"),
|
||||
)
|
||||
|
||||
settingsUI := testhelpers.NewSettingsUIFlowEchoServer(t, reg)
|
||||
_ = testhelpers.NewErrorTestServer(t, reg)
|
||||
_ = testhelpers.NewLoginUIWith401Response(t, conf)
|
||||
|
||||
browserIdentity1 := newIdentityWithPassword("john-browser@doe.com")
|
||||
apiIdentity1 := newIdentityWithPassword("john-api@doe.com")
|
||||
browserIdentity2 := newEmptyIdentity()
|
||||
apiIdentity2 := newEmptyIdentity()
|
||||
|
||||
publicTS, _ := testhelpers.NewKratosServer(t, reg)
|
||||
|
||||
browserUser1 := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, browserIdentity1)
|
||||
browserUser1.Jar.SetCookies(nosurfx.WithFakeCSRFCookie(t, reg, publicTS.URL))
|
||||
browserUser2 := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, browserIdentity2)
|
||||
browserUser2.Jar.SetCookies(nosurfx.WithFakeCSRFCookie(t, reg, publicTS.URL))
|
||||
apiUser1 := testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, apiIdentity1)
|
||||
apiUser2 := testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, apiIdentity2)
|
||||
|
||||
t.Run("case=should reject a new password if it is the same as the old one", func(t *testing.T) {
|
||||
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), []config.SelfServiceHook{{Name: "session"}})
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(context.Background(), config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil)
|
||||
})
|
||||
|
||||
cfg := client.NewConfiguration()
|
||||
u, err := url.Parse(publicTS.URL)
|
||||
require.NoError(t, err)
|
||||
cfg.Scheme = u.Scheme
|
||||
cfg.Host = u.Host
|
||||
cl := client.NewAPIClient(cfg)
|
||||
api := cl.FrontendAPI
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
// Create a new account.
|
||||
password := uuid.Must(uuid.NewV4()).String()
|
||||
var sessionToken string
|
||||
{
|
||||
registrationFlow, _, err := api.CreateNativeRegistrationFlow(t.Context()).Execute()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, registrationFlow)
|
||||
|
||||
registrationBody := client.UpdateRegistrationFlowBody{
|
||||
UpdateRegistrationFlowWithPasswordMethod: &client.UpdateRegistrationFlowWithPasswordMethod{
|
||||
Method: "password",
|
||||
Password: password,
|
||||
Traits: map[string]any{
|
||||
"email": uuid.Must(uuid.NewV4()).String() + "@ory.dev",
|
||||
},
|
||||
},
|
||||
}
|
||||
registration, _, err := api.UpdateRegistrationFlow(t.Context()).Flow(registrationFlow.Id).UpdateRegistrationFlowBody(registrationBody).Execute()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, registration)
|
||||
require.NotNil(t, registration.SessionToken)
|
||||
|
||||
sessionToken = *registration.SessionToken
|
||||
require.NotEmpty(t, sessionToken)
|
||||
}
|
||||
|
||||
// Create a settings flow.
|
||||
var settingsFlow *client.SettingsFlow
|
||||
{
|
||||
|
||||
var err error
|
||||
settingsFlow, _, err = api.CreateNativeSettingsFlow(t.Context()).XSessionToken(sessionToken).Execute()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, settingsFlow)
|
||||
}
|
||||
|
||||
// Try to set the same password: fails.
|
||||
{
|
||||
update := client.UpdateSettingsFlowBody{
|
||||
UpdateSettingsFlowWithPasswordMethod: &client.UpdateSettingsFlowWithPasswordMethod{
|
||||
Method: "password",
|
||||
Password: password,
|
||||
},
|
||||
}
|
||||
req := api.UpdateSettingsFlow(t.Context()).UpdateSettingsFlowBody(update).Flow(settingsFlow.Id).XSessionToken(sessionToken)
|
||||
settingsFlow, httpResp, err := api.UpdateSettingsFlowExecute(req)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, settingsFlow)
|
||||
require.NotNil(t, httpResp)
|
||||
require.Equal(t, http.StatusBadRequest, httpResp.StatusCode)
|
||||
}
|
||||
|
||||
// Try to set a different password: succeeds.
|
||||
{
|
||||
update := client.UpdateSettingsFlowBody{
|
||||
UpdateSettingsFlowWithPasswordMethod: &client.UpdateSettingsFlowWithPasswordMethod{
|
||||
Method: "password",
|
||||
Password: uuid.Must(uuid.NewV4()).String(),
|
||||
},
|
||||
}
|
||||
req := api.UpdateSettingsFlow(t.Context()).UpdateSettingsFlowBody(update).Flow(settingsFlow.Id).XSessionToken(sessionToken)
|
||||
settingsFlow, httpResp, err := api.UpdateSettingsFlowExecute(req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, settingsFlow)
|
||||
require.NotNil(t, httpResp)
|
||||
require.Equal(t, http.StatusOK, httpResp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
// Create a new account.
|
||||
password := uuid.Must(uuid.NewV4()).String()
|
||||
var cookie string
|
||||
{
|
||||
registrationFlow, _, err := api.CreateBrowserRegistrationFlow(t.Context()).Execute()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, registrationFlow)
|
||||
|
||||
csrfToken := registrationFlow.Ui.Nodes[0].Attributes.UiNodeInputAttributes.Value.(string)
|
||||
require.NotEmpty(t, csrfToken)
|
||||
|
||||
registrationBody := client.UpdateRegistrationFlowBody{
|
||||
UpdateRegistrationFlowWithPasswordMethod: &client.UpdateRegistrationFlowWithPasswordMethod{
|
||||
Method: "password",
|
||||
Password: password,
|
||||
Traits: map[string]any{
|
||||
"email": uuid.Must(uuid.NewV4()).String() + "@ory.dev",
|
||||
},
|
||||
CsrfToken: &csrfToken,
|
||||
},
|
||||
}
|
||||
|
||||
registration, httpResp, err := api.UpdateRegistrationFlow(t.Context()).Flow(registrationFlow.Id).UpdateRegistrationFlowBody(registrationBody).Execute()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, httpResp)
|
||||
require.NotNil(t, registration)
|
||||
cookie = httpResp.Header.Get("Set-Cookie")
|
||||
require.NotEmpty(t, cookie)
|
||||
}
|
||||
|
||||
// Create a settings flow.
|
||||
var settingsFlow *client.SettingsFlow
|
||||
var csrfToken string
|
||||
{
|
||||
|
||||
var err error
|
||||
settingsFlow, _, err = api.CreateBrowserSettingsFlow(t.Context()).Cookie(cookie).Execute()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, settingsFlow)
|
||||
|
||||
csrfToken = settingsFlow.Ui.Nodes[0].Attributes.UiNodeInputAttributes.Value.(string)
|
||||
require.NotEmpty(t, csrfToken)
|
||||
}
|
||||
|
||||
// Try to set the same password: fails.
|
||||
{
|
||||
update := client.UpdateSettingsFlowBody{
|
||||
UpdateSettingsFlowWithPasswordMethod: &client.UpdateSettingsFlowWithPasswordMethod{
|
||||
Method: "password",
|
||||
Password: password,
|
||||
CsrfToken: &csrfToken,
|
||||
},
|
||||
}
|
||||
req := api.UpdateSettingsFlow(t.Context()).UpdateSettingsFlowBody(update).Flow(settingsFlow.Id).Cookie(cookie)
|
||||
settingsFlow, httpResp, err := api.UpdateSettingsFlowExecute(req)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, settingsFlow)
|
||||
require.NotNil(t, httpResp)
|
||||
require.Equal(t, http.StatusBadRequest, httpResp.StatusCode)
|
||||
}
|
||||
|
||||
// Try to set a different password: succeeds.
|
||||
{
|
||||
update := client.UpdateSettingsFlowBody{
|
||||
UpdateSettingsFlowWithPasswordMethod: &client.UpdateSettingsFlowWithPasswordMethod{
|
||||
Method: "password",
|
||||
Password: uuid.Must(uuid.NewV4()).String(),
|
||||
CsrfToken: &csrfToken,
|
||||
},
|
||||
}
|
||||
req := api.UpdateSettingsFlow(t.Context()).UpdateSettingsFlowBody(update).Flow(settingsFlow.Id).Cookie(cookie)
|
||||
settingsFlow, httpResp, err := api.UpdateSettingsFlowExecute(req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, settingsFlow)
|
||||
require.NotNil(t, httpResp)
|
||||
require.Equal(t, http.StatusOK, httpResp.StatusCode)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("description=not authorized to call endpoints without a session", func(t *testing.T) {
|
||||
c := testhelpers.NewDebugClient(t)
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
res, err := c.Do(httpx.MustNewRequest("POST", publicTS.URL+settings.RouteSubmitFlow, strings.NewReader(url.Values{"foo": {"bar"}}.Encode()), "application/x-www-form-urlencoded"))
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
assert.EqualValues(t, http.StatusUnauthorized, res.StatusCode, "%+v", res.Request)
|
||||
assert.Contains(t, res.Request.URL.String(), conf.GetProvider(t.Context()).String(config.ViperKeySelfServiceLoginUI))
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
res, err := c.Do(httpx.MustNewRequest("POST", publicTS.URL+settings.RouteSubmitFlow, strings.NewReader(url.Values{"foo": {"bar"}}.Encode()), "application/json"))
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
assert.EqualValues(t, http.StatusUnauthorized, res.StatusCode, "%+v", res.Request)
|
||||
assert.Contains(t, res.Request.URL.String(), settings.RouteSubmitFlow)
|
||||
})
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
res, err := c.Do(httpx.MustNewRequest("POST", publicTS.URL+settings.RouteSubmitFlow, strings.NewReader(`{"foo":"bar"}`), "application/json"))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, res.Cookies(), 0)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
assert.EqualValues(t, http.StatusUnauthorized, res.StatusCode)
|
||||
})
|
||||
})
|
||||
|
||||
expectValidationError := func(t *testing.T, isAPI, isSPA bool, hc *http.Client, values func(url.Values)) string {
|
||||
return testhelpers.SubmitSettingsForm(t, isAPI, isSPA, hc, publicTS, values,
|
||||
testhelpers.ExpectStatusCode(isAPI || isSPA, http.StatusBadRequest, http.StatusOK),
|
||||
testhelpers.ExpectURL(isAPI || isSPA, publicTS.URL+settings.RouteSubmitFlow, conf.SelfServiceFlowSettingsUI(t.Context()).String()))
|
||||
}
|
||||
|
||||
t.Run("description=should fail if password violates policy", func(t *testing.T) {
|
||||
check := func(t *testing.T, reason, actual string) {
|
||||
assert.Empty(t, gjson.Get(actual, "ui.nodes.#(attributes.name==password).attributes.value").String(), "%s", actual)
|
||||
assert.NotEmpty(t, gjson.Get(actual, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String(), "%s", actual)
|
||||
assert.Equal(t, reason, gjson.Get(actual, "ui.nodes.#(attributes.name==password).messages.0.text").String(), "%s", actual)
|
||||
}
|
||||
|
||||
t.Run("session=with privileged session", func(t *testing.T) {
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, "5m")
|
||||
|
||||
payload := func(v url.Values) {
|
||||
v.Set("password", "123456")
|
||||
v.Set("method", "password")
|
||||
}
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
check(t, "The password must be at least 8 characters long, but got 6.", expectValidationError(t, true, false, apiUser1, payload))
|
||||
})
|
||||
|
||||
t.Run("spa=spa", func(t *testing.T) {
|
||||
check(t, "The password must be at least 8 characters long, but got 6.", expectValidationError(t, false, true, browserUser1, payload))
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
check(t, "The password must be at least 8 characters long, but got 6.", expectValidationError(t, false, false, browserUser1, payload))
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("session=needs reauthentication", func(t *testing.T) {
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, "1ns")
|
||||
defer testhelpers.NewLoginUIWith401Response(t, conf)
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(context.Background(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, "5m")
|
||||
})
|
||||
|
||||
payload := func(v url.Values) {
|
||||
v.Set("method", "password")
|
||||
v.Set("password", "123456")
|
||||
}
|
||||
|
||||
t.Run("type=api/expected=an error because reauth can not be initialized for API clients", func(t *testing.T) {
|
||||
_ = testhelpers.NewSettingsLoginAcceptAPIServer(t, testhelpers.NewSDKCustomClient(publicTS, apiUser1), conf)
|
||||
actual := testhelpers.SubmitSettingsForm(t, true, false, apiUser1, publicTS, payload,
|
||||
http.StatusForbidden, publicTS.URL+settings.RouteSubmitFlow)
|
||||
assertx.EqualAsJSONExcept(t, settings.NewFlowNeedsReAuth(), json.RawMessage(actual), []string{"redirect_browser_to"})
|
||||
assert.NotEmpty(t, json.RawMessage(gjson.Get(actual, "redirect_browser_to").String()))
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
_ = testhelpers.NewSettingsLoginAcceptAPIServer(t, testhelpers.NewSDKCustomClient(publicTS, browserUser1), conf)
|
||||
actual := testhelpers.SubmitSettingsForm(t, false, true, browserUser1, publicTS, payload,
|
||||
http.StatusForbidden, publicTS.URL+settings.RouteSubmitFlow)
|
||||
assertx.EqualAsJSON(t, settings.NewFlowNeedsReAuth().DefaultError, json.RawMessage(gjson.Get(actual, "error").Raw))
|
||||
assert.NotEmpty(t, json.RawMessage(gjson.Get(actual, "redirect_browser_to").String()))
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
_ = testhelpers.NewSettingsLoginAcceptAPIServer(t, testhelpers.NewSDKCustomClient(publicTS, browserUser1), conf)
|
||||
check(t, "The password must be at least 8 characters long, but got 6.", expectValidationError(t, false, false, browserUser1, payload))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("description=should not be able to make requests for another user", func(t *testing.T) {
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
f := testhelpers.InitializeSettingsFlowViaAPI(t, apiUser1, publicTS)
|
||||
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
||||
values.Set("method", "password")
|
||||
values.Set("password", x.NewUUID().String())
|
||||
actual, res := testhelpers.SettingsMakeRequest(t, true, false, f, apiUser2, testhelpers.EncodeFormAsJSON(t, true, values))
|
||||
assert.Equal(t, http.StatusForbidden, res.StatusCode)
|
||||
assert.Contains(t, gjson.Get(actual, "error.reason").String(), "initiated by someone else", "%s", actual)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserUser1, true, publicTS)
|
||||
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
||||
values.Set("method", "password")
|
||||
values.Set("password", x.NewUUID().String())
|
||||
actual, res := testhelpers.SettingsMakeRequest(t, false, true, f, browserUser2, values.Encode())
|
||||
assert.Equal(t, http.StatusForbidden, res.StatusCode)
|
||||
assert.Contains(t, gjson.Get(actual, "error.reason").String(), "initiated by someone else", "%s", actual)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserUser1, false, publicTS)
|
||||
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
||||
values.Set("method", "password")
|
||||
values.Set("password", x.NewUUID().String())
|
||||
actual, res := testhelpers.SettingsMakeRequest(t, false, false, f, browserUser2, values.Encode())
|
||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||
assert.Contains(t, gjson.Get(actual, "reason").String(), "initiated by someone else", "%s", actual)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("description=should update the password and clear errors if everything is ok", func(t *testing.T) {
|
||||
check := func(t *testing.T, actual string) {
|
||||
assert.Equal(t, "success", gjson.Get(actual, "state").String(), "%s", actual)
|
||||
assert.Empty(t, gjson.Get(actual, "ui.nodes.#(attributes.name==password).value").String(), "%s", actual)
|
||||
assert.Empty(t, gjson.Get(actual, "ui.nodes.#(attributes.name==password).messages.0.text").String(), actual)
|
||||
}
|
||||
|
||||
payload := func(v url.Values) {
|
||||
v.Set("method", "password")
|
||||
v.Set("password", x.NewUUID().String())
|
||||
}
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
actual := testhelpers.SubmitSettingsForm(t, true, false, apiUser1, publicTS, payload, http.StatusOK, publicTS.URL+settings.RouteSubmitFlow)
|
||||
check(t, actual)
|
||||
assert.Empty(t, gjson.Get(actual, "continue_with").Array(), "%s", actual)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
actual := testhelpers.SubmitSettingsForm(t, false, true, browserUser1, publicTS, payload, http.StatusOK, publicTS.URL+settings.RouteSubmitFlow)
|
||||
check(t, actual)
|
||||
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(), settingsUI.URL, "%s", actual)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
actual := testhelpers.SubmitSettingsForm(t, false, false, browserUser1, publicTS, payload, http.StatusOK, conf.SelfServiceFlowSettingsUI(t.Context()).String())
|
||||
check(t, actual)
|
||||
assert.Empty(t, gjson.Get(actual, "continue_with").Array(), "%s", actual)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=should fail because of missing CSRF token/type=browser", func(t *testing.T) {
|
||||
f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserUser1, false, publicTS)
|
||||
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
||||
values.Set("method", "password")
|
||||
values.Set("password", x.NewUUID().String())
|
||||
values.Set("csrf_token", "invalid_token")
|
||||
|
||||
actual, res := testhelpers.SettingsMakeRequest(t, false, false, f, browserUser1, values.Encode())
|
||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||
assert.Contains(t, res.Request.URL.String(), conf.GetProvider(t.Context()).String(config.ViperKeySelfServiceErrorUI))
|
||||
|
||||
assertx.EqualAsJSON(t, nosurfx.ErrInvalidCSRFTokenServerTokenMismatch(), json.RawMessage(actual), "%s", actual)
|
||||
})
|
||||
|
||||
t.Run("case=should pass even without CSRF token/type=spa", func(t *testing.T) {
|
||||
f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserUser1, true, publicTS)
|
||||
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
||||
values.Set("method", "password")
|
||||
values.Set("password", x.NewUUID().String())
|
||||
values.Set("csrf_token", "invalid_token")
|
||||
actual, res := testhelpers.SettingsMakeRequest(t, false, true, f, browserUser1, testhelpers.EncodeFormAsJSON(t, true, values))
|
||||
assert.Equal(t, http.StatusForbidden, res.StatusCode)
|
||||
|
||||
assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow)
|
||||
assertx.EqualAsJSON(t, nosurfx.ErrInvalidCSRFTokenAJAXTokenMismatch(), json.RawMessage(gjson.Get(actual, "error").Raw), "%s", actual)
|
||||
})
|
||||
|
||||
t.Run("case=should pass even without CSRF token/type=api", func(t *testing.T) {
|
||||
f := testhelpers.InitializeSettingsFlowViaAPI(t, apiUser1, publicTS)
|
||||
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
||||
values.Set("method", "password")
|
||||
values.Set("password", x.NewUUID().String())
|
||||
values.Set("csrf_token", "invalid_token")
|
||||
actual, res := testhelpers.SettingsMakeRequest(t, true, false, f, apiUser1, testhelpers.EncodeFormAsJSON(t, true, values))
|
||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||
|
||||
assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow)
|
||||
assert.NotEmpty(t, gjson.Get(actual, "identity.id").String(), "%s", actual)
|
||||
})
|
||||
|
||||
t.Run("case=should fail with correct CSRF error cause/type=api", func(t *testing.T) {
|
||||
for k, tc := range []struct {
|
||||
mod func(http.Header)
|
||||
exp string
|
||||
}{
|
||||
{
|
||||
mod: func(h http.Header) {
|
||||
h.Add("Cookie", "name=bar")
|
||||
},
|
||||
exp: "The HTTP Request Header included the \\\"Cookie\\\" key",
|
||||
},
|
||||
{
|
||||
mod: func(h http.Header) {
|
||||
h.Add("Origin", "www.bar.com")
|
||||
},
|
||||
exp: "The HTTP Request Header included the \\\"Origin\\\" key",
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
|
||||
f := testhelpers.InitializeSettingsFlowViaAPI(t, apiUser1, publicTS)
|
||||
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
||||
values.Set("password", x.NewUUID().String())
|
||||
|
||||
req := testhelpers.NewPostRequest(t, true, f.Ui.Action, bytes.NewBufferString(testhelpers.EncodeFormAsJSON(t, true, values)))
|
||||
tc.mod(req.Header)
|
||||
|
||||
res, err := apiUser1.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
actual := string(ioutilx.MustReadAll(res.Body))
|
||||
assert.EqualValues(t, http.StatusBadRequest, res.StatusCode)
|
||||
assert.Contains(t, actual, tc.exp)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
expectSuccess := func(t *testing.T, isAPI, isSPA bool, hc *http.Client, values func(url.Values)) string {
|
||||
return testhelpers.SubmitSettingsForm(t, isAPI, isSPA, hc, publicTS, values, http.StatusOK,
|
||||
testhelpers.ExpectURL(isAPI || isSPA, publicTS.URL+settings.RouteSubmitFlow, conf.SelfServiceFlowSettingsUI(t.Context()).String()))
|
||||
}
|
||||
|
||||
t.Run("description=should update the password even if no password was set before", func(t *testing.T) {
|
||||
bi := newIdentityWithoutCredentials(x.NewUUID().String() + "@ory.sh")
|
||||
si := newIdentityWithoutCredentials(x.NewUUID().String() + "@ory.sh")
|
||||
ai := newIdentityWithoutCredentials(x.NewUUID().String() + "@ory.sh")
|
||||
browserUser := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, bi)
|
||||
spaUser := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, si)
|
||||
apiUser := testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, ai)
|
||||
|
||||
check := func(t *testing.T, actual string, id *identity.Identity) {
|
||||
assert.Equal(t, "success", gjson.Get(actual, "state").String(), "%s", actual)
|
||||
assert.Empty(t, gjson.Get(actual, "ui.nodes.#(name==password).attributes.value").String(), "%s", actual)
|
||||
|
||||
actualIdentity, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(context.Background(), id.ID)
|
||||
require.NoError(t, err)
|
||||
cfg := string(actualIdentity.Credentials[identity.CredentialsTypePassword].Config)
|
||||
assert.Contains(t, cfg, "hashed_password", "%+v", actualIdentity.Credentials)
|
||||
require.Len(t, actualIdentity.Credentials[identity.CredentialsTypePassword].Identifiers, 1)
|
||||
assert.Contains(t, actualIdentity.Credentials[identity.CredentialsTypePassword].Identifiers[0], "-4")
|
||||
}
|
||||
|
||||
payload := func(v url.Values) {
|
||||
v.Set("method", "password")
|
||||
v.Set("password", randx.MustString(16, randx.AlphaNum))
|
||||
}
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
actual := expectSuccess(t, true, false, apiUser, payload)
|
||||
check(t, actual, ai)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
actual := expectSuccess(t, false, true, spaUser, payload)
|
||||
check(t, actual, si)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
actual := expectSuccess(t, false, false, browserUser, payload)
|
||||
check(t, actual, bi)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("description=should update the password and perform the correct redirection", func(t *testing.T) {
|
||||
rts := testhelpers.NewRedirTS(t, "", conf)
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsAfter+"."+config.DefaultBrowserReturnURL, rts.URL+"/return-ts")
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(context.Background(), config.ViperKeySelfServiceSettingsAfter, nil)
|
||||
})
|
||||
|
||||
run := func(t *testing.T, f *kratos.SettingsFlow, isAPI bool, c *http.Client, _ *identity.Identity) {
|
||||
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
||||
values.Set("method", "password")
|
||||
values.Set("password", randx.MustString(16, randx.AlphaNum))
|
||||
_, res := testhelpers.SettingsMakeRequest(t, isAPI, false, f, c, testhelpers.EncodeFormAsJSON(t, isAPI, values))
|
||||
require.EqualValues(t, rts.URL+"/return-ts", res.Request.URL.String())
|
||||
|
||||
actualIdentity, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(context.Background(), browserIdentity1.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := string(actualIdentity.Credentials[identity.CredentialsTypePassword].Config)
|
||||
assert.NotContains(t, cfg, "foo")
|
||||
assert.NotEqual(t, `{"hashed_password":"foo"}`, cfg)
|
||||
}
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
t.Skip("Post-registration redirects do not work for API flows and are thus not tested here.")
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
t.Skip("Post-registration redirects do not work for API flows and are thus not tested here.")
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
rs := testhelpers.InitializeSettingsFlowViaBrowser(t, browserUser1, false, publicTS)
|
||||
run(t, rs, false, browserUser1, browserIdentity1)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("description=should update the password and revoke other user sessions", func(t *testing.T) {
|
||||
conf.MustSet(t.Context(), config.HookStrategyKey(config.ViperKeySelfServiceSettingsAfter, "password"), []config.SelfServiceHook{{Name: "revoke_active_sessions"}})
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(context.Background(), config.ViperKeySelfServiceSettingsAfter, nil)
|
||||
})
|
||||
|
||||
check := func(t *testing.T, actual string, id *identity.Identity) {
|
||||
assert.Equal(t, "success", gjson.Get(actual, "state").String(), "%s", actual)
|
||||
assert.Empty(t, gjson.Get(actual, "ui.nodes.#(name==password).attributes.value").String(), "%s", actual)
|
||||
|
||||
actualIdentity, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(context.Background(), id.ID)
|
||||
require.NoError(t, err)
|
||||
cfg := string(actualIdentity.Credentials[identity.CredentialsTypePassword].Config)
|
||||
assert.Contains(t, cfg, "hashed_password", "%+v", actualIdentity.Credentials)
|
||||
require.Len(t, actualIdentity.Credentials[identity.CredentialsTypePassword].Identifiers, 1)
|
||||
assert.Contains(t, actualIdentity.Credentials[identity.CredentialsTypePassword].Identifiers[0], "-4")
|
||||
}
|
||||
|
||||
initClients := func(isAPI bool, id *identity.Identity) (client1, client2 *http.Client) {
|
||||
if isAPI {
|
||||
client1 = testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, id)
|
||||
client2 = testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, id)
|
||||
return client1, client2
|
||||
}
|
||||
client1 = testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, id)
|
||||
client2 = testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, id)
|
||||
|
||||
return client1, client2
|
||||
}
|
||||
|
||||
run := func(t *testing.T, isAPI, isSPA bool, id *identity.Identity) {
|
||||
payload := func(v url.Values) {
|
||||
v.Set("method", "password")
|
||||
v.Set("password", randx.MustString(16, randx.AlphaNum))
|
||||
}
|
||||
|
||||
user1, user2 := initClients(isAPI, id)
|
||||
|
||||
actual := expectSuccess(t, isAPI, isSPA, user1, payload)
|
||||
check(t, actual, id)
|
||||
|
||||
// second client should be logged out
|
||||
res, err := user2.Do(httpx.MustNewRequest("POST", publicTS.URL+settings.RouteSubmitFlow, strings.NewReader(url.Values{"foo": {"bar"}}.Encode()), "application/json"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, res.Body.Close())
|
||||
assert.EqualValues(t, http.StatusUnauthorized, res.StatusCode, "%+v", res.Request)
|
||||
|
||||
// again change password via first client
|
||||
actual = expectSuccess(t, isAPI, isSPA, user1, payload)
|
||||
check(t, actual, id)
|
||||
}
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
id := newIdentityWithoutCredentials(x.NewUUID().String() + "@ory.sh")
|
||||
run(t, true, false, id)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
id := newIdentityWithoutCredentials(x.NewUUID().String() + "@ory.sh")
|
||||
run(t, false, true, id)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
id := newIdentityWithoutCredentials(x.NewUUID().String() + "@ory.sh")
|
||||
run(t, false, false, id)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=should fail if no identifier was set in the schema", func(t *testing.T) {
|
||||
testhelpers.SetDefaultIdentitySchema(conf, "file://stub/missing-identifier.schema.json")
|
||||
|
||||
id := newIdentityWithoutCredentials(testhelpers.RandomEmail())
|
||||
browser := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, id)
|
||||
api := testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, id)
|
||||
|
||||
for _, f := range []string{"spa", "api", "browser"} {
|
||||
t.Run("type="+f, func(t *testing.T) {
|
||||
hc := browser
|
||||
if f == "api" {
|
||||
hc = api
|
||||
}
|
||||
actual := settingshelpers.ExpectValidationError(t, publicTS, hc, conf, f, func(v url.Values) {
|
||||
v.Set("password", x.NewUUID().String())
|
||||
v.Set("method", "password")
|
||||
})
|
||||
assert.Equal(t, text.NewErrorValidationIdentifierMissing().Text, gjson.Get(actual, "ui.messages.0.text").String(), "%s", actual)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
120
selfservice/strategy/password/strategy.go
Normal file
120
selfservice/strategy/password/strategy.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/kratos/ui/node"
|
||||
"github.com/ory/x/httpx"
|
||||
"github.com/ory/x/jsonnetsecure"
|
||||
"github.com/ory/x/logrusx"
|
||||
"github.com/ory/x/otelx"
|
||||
|
||||
"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/x"
|
||||
)
|
||||
|
||||
var (
|
||||
_ login.Strategy = (*Strategy)(nil)
|
||||
_ registration.Strategy = (*Strategy)(nil)
|
||||
_ identity.ActiveCredentialsCounter = (*Strategy)(nil)
|
||||
)
|
||||
|
||||
type dependencies interface {
|
||||
logrusx.Provider
|
||||
httpx.WriterProvider
|
||||
nosurfx.CSRFTokenGeneratorProvider
|
||||
nosurfx.CSRFProvider
|
||||
httpx.ClientProvider
|
||||
otelx.Provider
|
||||
jsonnetsecure.VMProvider
|
||||
config.Provider
|
||||
continuity.ManagementProvider
|
||||
|
||||
x.CookieProvider
|
||||
|
||||
errorx.ManagementProvider
|
||||
ValidationProvider
|
||||
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(ctx 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.CredentialsPassword
|
||||
if err = json.Unmarshal(c.Config, &conf); err != nil {
|
||||
return 0, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if len(strings.Join(c.Identifiers, "")) > 0 &&
|
||||
((s.d.Config().PasswordMigrationHook(ctx).Enabled && conf.UsePasswordMigrationHook) ||
|
||||
len(conf.HashedPassword) > 0) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Strategy) CountActiveMultiFactorCredentials(_ context.Context, _ map[identity.CredentialsType]identity.Credentials) (count int, err error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *Strategy) ID() identity.CredentialsType {
|
||||
return identity.CredentialsTypePassword
|
||||
}
|
||||
|
||||
func (s *Strategy) CompletedAuthenticationMethod(_ context.Context) session.AuthenticationMethod {
|
||||
return session.AuthenticationMethod{
|
||||
Method: s.ID(),
|
||||
AAL: identity.AuthenticatorAssuranceLevel1,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Strategy) NodeGroup() node.UiNodeGroup {
|
||||
return node.PasswordGroup
|
||||
}
|
||||
82
selfservice/strategy/password/strategy_disabled_test.go
Normal file
82
selfservice/strategy/password/strategy_disabled_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
"github.com/ory/x/configx"
|
||||
)
|
||||
|
||||
func TestDisabledEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t,
|
||||
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypePassword, false)),
|
||||
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://stub/sort.schema.json")),
|
||||
)
|
||||
|
||||
publicTS, _ := testhelpers.NewKratosServer(t, reg)
|
||||
|
||||
c := testhelpers.NewClientWithCookies(t)
|
||||
t.Run("case=should not login when password method is disabled", func(t *testing.T) {
|
||||
f := testhelpers.InitializeLoginFlowViaAPICtx(context.Background(), t, c, publicTS, false)
|
||||
|
||||
res, err := c.PostForm(f.Ui.Action, url.Values{"method": {"password"}, "password_identifier": []string{"identifier"}, "password": []string{"password"}})
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
assert.Equal(t, http.StatusNotFound, res.StatusCode)
|
||||
|
||||
b, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(b), "This endpoint was disabled by system administrator", "%s", b)
|
||||
})
|
||||
|
||||
t.Run("case=should not registration when password method is disabled", func(t *testing.T) {
|
||||
f := testhelpers.InitializeRegistrationFlowViaAPICtx(t.Context(), t, c, publicTS)
|
||||
|
||||
res, err := c.PostForm(f.Ui.Action, url.Values{"method": {"password"}, "password_identifier": []string{"identifier"}, "password": []string{"password"}})
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
assert.Equal(t, http.StatusNotFound, res.StatusCode)
|
||||
|
||||
b, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(b), "This endpoint was disabled by system administrator", "%s", b)
|
||||
})
|
||||
|
||||
t.Run("case=should not settings when password method is disabled", func(t *testing.T) {
|
||||
testhelpers.SetDefaultIdentitySchema(conf, "file://stub/login.schema.json")
|
||||
c := testhelpers.NewHTTPClientWithArbitrarySessionCookie(context.Background(), t, reg)
|
||||
|
||||
t.Run("method=GET", func(t *testing.T) {
|
||||
t.Skip("GET is currently not supported for this endpoint.")
|
||||
})
|
||||
|
||||
t.Run("method=POST", func(t *testing.T) {
|
||||
f := testhelpers.InitializeSettingsFlowViaAPI(t, c, publicTS)
|
||||
res, err := c.PostForm(f.Ui.Action, url.Values{
|
||||
"method": {"password"},
|
||||
"password": {"bar"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
assert.Equal(t, http.StatusNotFound, res.StatusCode)
|
||||
|
||||
b, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(b), "This endpoint was disabled by system administrator", "%s", b)
|
||||
})
|
||||
})
|
||||
}
|
||||
168
selfservice/strategy/password/strategy_test.go
Normal file
168
selfservice/strategy/password/strategy_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password_test
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/go-faker/faker/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/hash"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/kratos/selfservice/strategy/password"
|
||||
"github.com/ory/x/contextx"
|
||||
)
|
||||
|
||||
func generateRandomConfig(t *testing.T) (identity.CredentialsPassword, []byte) {
|
||||
t.Helper()
|
||||
var cred identity.CredentialsPassword
|
||||
require.NoError(t, faker.FakeData(&cred))
|
||||
c, err := json.Marshal(cred)
|
||||
require.NoError(t, err)
|
||||
return cred, c
|
||||
}
|
||||
|
||||
func TestCountActiveFirstFactorCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
strategy := password.NewStrategy(reg)
|
||||
|
||||
h1, err := hash.NewHasherBcrypt(reg).Generate(t.Context(), []byte("a password"))
|
||||
require.NoError(t, err)
|
||||
h2, err := reg.Hasher(t.Context()).Generate(t.Context(), []byte("a password"))
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("test regressions fixtures", func(t *testing.T) {
|
||||
// This test ensures we do not add regressions to this method by, for example, adding a new field.
|
||||
for k := range 100 {
|
||||
t.Run(fmt.Sprintf("run=%d", k), func(t *testing.T) {
|
||||
cred, c := generateRandomConfig(t)
|
||||
actual, err := strategy.CountActiveFirstFactorCredentials(t.Context(), map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
|
||||
Type: strategy.ID(),
|
||||
Identifiers: []string{"foo"},
|
||||
Config: c,
|
||||
}})
|
||||
assert.NoError(t, err)
|
||||
|
||||
if len(cred.HashedPassword) == 0 && cred.UsePasswordMigrationHook {
|
||||
// This case is OK
|
||||
assert.Equal(t, 0, actual)
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, 1, actual)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with fixtures", func(t *testing.T) {
|
||||
for k, tc := range []struct {
|
||||
in map[identity.CredentialsType]identity.Credentials
|
||||
expected int
|
||||
ctx context.Context
|
||||
}{
|
||||
{
|
||||
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(`{"hashed_password": "` + string(h1) + `"}`),
|
||||
}},
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
|
||||
Type: strategy.ID(),
|
||||
Identifiers: []string{""},
|
||||
Config: []byte(`{"hashed_password": "` + string(h1) + `"}`),
|
||||
}},
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
|
||||
Type: strategy.ID(),
|
||||
Identifiers: []string{"foo"},
|
||||
Config: []byte(`{"hashed_password": "` + string(h1) + `"}`),
|
||||
}},
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
|
||||
Type: strategy.ID(),
|
||||
Identifiers: []string{"foo"},
|
||||
Config: []byte(`{"hashed_password": "` + string(h2) + `"}`),
|
||||
}},
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
|
||||
Type: strategy.ID(),
|
||||
Identifiers: []string{"foo"},
|
||||
Config: []byte(`{"use_password_migration_hook":true}`),
|
||||
}},
|
||||
expected: 1,
|
||||
ctx: contextx.WithConfigValue(t.Context(), config.ViperKeyPasswordMigrationHook+".enabled", true),
|
||||
},
|
||||
{
|
||||
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
|
||||
Type: strategy.ID(),
|
||||
Identifiers: []string{"foo"},
|
||||
Config: []byte(`{"use_password_migration_hook":true}`),
|
||||
}},
|
||||
expected: 0,
|
||||
ctx: contextx.WithConfigValue(t.Context(), config.ViperKeyPasswordMigrationHook+".enabled", false),
|
||||
},
|
||||
{
|
||||
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
|
||||
Type: strategy.ID(),
|
||||
Identifiers: []string{"foo"},
|
||||
Config: []byte(`{"use_password_migration_hook":false}`),
|
||||
}},
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
|
||||
Type: strategy.ID(),
|
||||
Config: []byte(`{"hashed_password": "asdf"}`),
|
||||
}},
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
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.CountActiveFirstFactorCredentials(cmp.Or(tc.ctx, t.Context()), tc.in)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, actual)
|
||||
|
||||
actual, err = strategy.CountActiveMultiFactorCredentials(cmp.Or(tc.ctx, t.Context()), tc.in)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, actual)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
50
selfservice/strategy/password/stub/email.schema.json
Normal file
50
selfservice/strategy/password/stub/email.schema.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"$id": "https://schemas.ory.sh/presets/kratos/identity.email.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"title": "E-Mail",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
},
|
||||
"webauthn": {
|
||||
"identifier": true
|
||||
},
|
||||
"totp": {
|
||||
"account_name": true
|
||||
},
|
||||
"code": {
|
||||
"identifier": true,
|
||||
"via": "email"
|
||||
},
|
||||
"passkey": {
|
||||
"display_name": true
|
||||
}
|
||||
},
|
||||
"recovery": {
|
||||
"via": "email"
|
||||
},
|
||||
"verification": {
|
||||
"via": "email"
|
||||
},
|
||||
"organizations": {
|
||||
"matcher": "email_domain"
|
||||
}
|
||||
},
|
||||
"maxLength": 320
|
||||
}
|
||||
},
|
||||
"required": ["email"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
11
selfservice/strategy/password/stub/login.schema.json
Normal file
11
selfservice/strategy/password/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"
|
||||
}
|
||||
}
|
||||
}
|
||||
36
selfservice/strategy/password/stub/migration.schema.json
Normal file
36
selfservice/strategy/password/stub/migration.schema.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"$id": "https://example.com/person.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
},
|
||||
"webauthn": {
|
||||
"identifier": true
|
||||
}
|
||||
},
|
||||
"verification": {
|
||||
"via": "email"
|
||||
},
|
||||
"recovery": {
|
||||
"via": "email"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$id": "https://example.com/person.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
44
selfservice/strategy/password/stub/phone.schema.json
Normal file
44
selfservice/strategy/password/stub/phone.schema.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"$id": "https://schemas.ory.sh/presets/kratos/identity.sms.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"phone_number": {
|
||||
"type": "string",
|
||||
"format": "tel",
|
||||
"title": "Phone Number",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
},
|
||||
"webauthn": {
|
||||
"identifier": true
|
||||
},
|
||||
"totp": {
|
||||
"account_name": true
|
||||
},
|
||||
"code": {
|
||||
"identifier": true,
|
||||
"via": "sms"
|
||||
},
|
||||
"passkey": {
|
||||
"display_name": true
|
||||
}
|
||||
},
|
||||
"verification": {
|
||||
"via": "sms"
|
||||
}
|
||||
},
|
||||
"maxLength": 320
|
||||
}
|
||||
},
|
||||
"required": ["phone_number"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
25
selfservice/strategy/password/stub/profile.schema.json
Normal file
25
selfservice/strategy/password/stub/profile.schema.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$id": "https://example.com/person.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
29
selfservice/strategy/password/stub/registration.schema.json
Normal file
29
selfservice/strategy/password/stub/registration.schema.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$id": "https://example.com/person.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"foobar": {
|
||||
"type": "string",
|
||||
"minLength": 2
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["foobar", "username"]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$id": "https://example.com/person.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"baz": {
|
||||
"type": "string",
|
||||
"minLength": 2
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"baz",
|
||||
"username"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
26
selfservice/strategy/password/stub/sort.schema.json
Normal file
26
selfservice/strategy/password/stub/sort.schema.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$id": "https://example.com/registration.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "string",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"bar": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
selfservice/strategy/password/types.go
Normal file
38
selfservice/strategy/password/types.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Update Login Flow with Password Method
|
||||
//
|
||||
// swagger:model updateLoginFlowWithPasswordMethod
|
||||
type updateLoginFlowWithPasswordMethod struct {
|
||||
// Method should be set to "password" when logging in using the identifier and password 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 user's password.
|
||||
//
|
||||
// required: true
|
||||
Password string `json:"password"`
|
||||
|
||||
// Identifier is the email or username of the user trying to log in.
|
||||
// This field is deprecated!
|
||||
LegacyIdentifier string `json:"password_identifier"`
|
||||
|
||||
// Identifier is the email or username of the user trying to log in.
|
||||
//
|
||||
// required: true
|
||||
Identifier string `json:"identifier"`
|
||||
|
||||
// Transient data to pass along to any webhooks
|
||||
//
|
||||
// required: false
|
||||
TransientPayload json.RawMessage `json:"transient_payload,omitempty" form:"transient_payload"`
|
||||
}
|
||||
238
selfservice/strategy/password/validator.go
Normal file
238
selfservice/strategy/password/validator.go
Normal file
@@ -0,0 +1,238 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha1" //#nosec G505 -- sha1 is used for k-anonymity
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ory/kratos/text"
|
||||
|
||||
"github.com/arbovm/levenshtein"
|
||||
"github.com/dgraph-io/ristretto/v2"
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/x/httpx"
|
||||
"github.com/ory/x/otelx"
|
||||
)
|
||||
|
||||
const hashCacheItemTTL = time.Hour
|
||||
|
||||
// maxIdentifierSimilarityCheckLength bounds the operands of the O(n·m)
|
||||
// identifier-similarity check to keep its worst-case cost constant. Real
|
||||
// identifiers (emails, usernames) and passwords are far shorter than this, so
|
||||
// legitimate flows are unaffected, while oversized operands are skipped to keep
|
||||
// the check's cost bounded. The bound is checked on the raw byte length as a
|
||||
// cheap pre-filter and again on the case-folded operands, since lowercasing can
|
||||
// expand an operand (e.g. Turkish dotted I).
|
||||
const maxIdentifierSimilarityCheckLength = 256
|
||||
|
||||
// Validator implements a validation strategy for passwords. One example is that the password
|
||||
// has to have at least 6 characters and at least one lower and one uppercase password.
|
||||
type Validator interface {
|
||||
// Validate returns nil if the password is passing the validation strategy and an error otherwise. If a validation error
|
||||
// occurs, a regular error will be returned. If some other type of error occurs (e.g. HTTP request failed), an error
|
||||
// of type *herodot.DefaultError will be returned.
|
||||
Validate(ctx context.Context, identifier, password string) error
|
||||
}
|
||||
|
||||
type ValidationProvider interface {
|
||||
PasswordValidator() Validator
|
||||
}
|
||||
|
||||
var (
|
||||
_ Validator = new(DefaultPasswordValidator)
|
||||
ErrNetworkFailure = herodot.ErrUpstreamError().WithError("Leaked password server unavailable").WithReasonf("Unable to check if password has been leaked because an unexpected network error occurred")
|
||||
ErrUnexpectedStatusCode = herodot.ErrUpstreamError().WithError("Leaked password server unavailable").WithReasonf("Unexpected status code from haveibeenpwned.com")
|
||||
)
|
||||
|
||||
// DefaultPasswordValidator implements Validator. It is based on best
|
||||
// practices as defined in the following blog posts:
|
||||
//
|
||||
// - https://www.troyhunt.com/passwords-evolved-authentication-guidance-for-the-modern-era/
|
||||
// - https://www.microsoft.com/en-us/research/wp-content/uploads/2016/06/Microsoft_Password_Guidance-1.pdf
|
||||
//
|
||||
// Additionally passwords are being checked against Troy Hunt's
|
||||
// [haveibeenpwnd](https://haveibeenpwned.com/API/v2#SearchingPwnedPasswordsByRange) service to check if the
|
||||
// password has been breached in a previous data leak using k-anonymity.
|
||||
type DefaultPasswordValidator struct {
|
||||
reg validatorDependencies
|
||||
hashes *ristretto.Cache[string, int64]
|
||||
|
||||
minIdentifierPasswordDist int
|
||||
maxIdentifierPasswordSubstrThreshold float32
|
||||
}
|
||||
|
||||
type validatorDependencies interface {
|
||||
config.Provider
|
||||
httpx.ClientProvider
|
||||
}
|
||||
|
||||
func NewDefaultPasswordValidatorStrategy(reg validatorDependencies) (*DefaultPasswordValidator, error) {
|
||||
cache, err := ristretto.NewCache(&ristretto.Config[string, int64]{
|
||||
NumCounters: 10 * 10000,
|
||||
MaxCost: 60 * 10000, // BCrypt hash size is 60 bytes
|
||||
BufferItems: 64,
|
||||
IgnoreInternalCost: true,
|
||||
})
|
||||
// sanity check - this should never happen unless above configuration variables are invalid
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error while setting up validator cache")
|
||||
}
|
||||
return &DefaultPasswordValidator{
|
||||
reg: reg,
|
||||
hashes: cache,
|
||||
minIdentifierPasswordDist: 5, maxIdentifierPasswordSubstrThreshold: 0.5,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func b20(src []byte) string {
|
||||
return fmt.Sprintf("%X", src)
|
||||
}
|
||||
|
||||
// code inspired by https://rosettacode.org/wiki/Longest_Common_Substring#Go
|
||||
func lcsLength(a, b string) int {
|
||||
lengths := make([]int, len(a)*len(b))
|
||||
greatestLength := 0
|
||||
for i, x := range a {
|
||||
for j, y := range b {
|
||||
if x == y {
|
||||
curr := 1
|
||||
if i != 0 && j != 0 {
|
||||
curr = lengths[(i-1)*len(b)+j-1] + 1
|
||||
}
|
||||
|
||||
if curr > greatestLength {
|
||||
greatestLength = curr
|
||||
}
|
||||
lengths[i*len(b)+j] = curr
|
||||
}
|
||||
}
|
||||
}
|
||||
return greatestLength
|
||||
}
|
||||
|
||||
func (s *DefaultPasswordValidator) fetch(ctx context.Context, hpw []byte, apiDNSName string) (int64, error) {
|
||||
prefix := fmt.Sprintf("%X", hpw)[0:5]
|
||||
loc := fmt.Sprintf("https://%s/range/%s", apiDNSName, prefix)
|
||||
req, err := retryablehttp.NewRequestWithContext(ctx, "GET", loc, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
res, err := s.reg.HTTPClient(ctx, httpx.ResilientClientWithConnectionTimeout(time.Second)).Do(req)
|
||||
if err != nil {
|
||||
return 0, errors.Wrapf(ErrNetworkFailure, "%s", err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return 0, errors.Wrapf(ErrUnexpectedStatusCode, "%d", res.StatusCode)
|
||||
}
|
||||
|
||||
var thisCount int64
|
||||
|
||||
sc := bufio.NewScanner(res.Body)
|
||||
for sc.Scan() {
|
||||
row := sc.Text()
|
||||
result := strings.Split(strings.TrimSpace(row), ":")
|
||||
|
||||
// We assume a count of 1. HIBP API sometimes responds without the
|
||||
// colon, so we just assume that the leak count is one.
|
||||
//
|
||||
// See https://github.com/ory/kratos/issues/2145
|
||||
count := int64(1)
|
||||
if len(result) == 2 {
|
||||
count, err = strconv.ParseInt(strings.ReplaceAll(result[1], ",", ""), 10, 64)
|
||||
if err != nil {
|
||||
return 0, errors.WithStack(herodot.ErrUpstreamError().WithReasonf("Expected password hash to contain a count formatted as int but got: %s", result[1]))
|
||||
}
|
||||
}
|
||||
|
||||
s.hashes.SetWithTTL(prefix+result[0], count, 1, hashCacheItemTTL)
|
||||
if prefix+result[0] == b20(hpw) {
|
||||
thisCount = count
|
||||
}
|
||||
}
|
||||
|
||||
// A read that fails after the response headers arrived (connection reset,
|
||||
// client timeout, truncated body) is as much a network failure as a failed
|
||||
// request, so it has to be classified as one to stay subject to
|
||||
// ignore_network_errors.
|
||||
if err := sc.Err(); err != nil {
|
||||
return 0, errors.Wrapf(ErrNetworkFailure, "unable to read the response body: %s", err)
|
||||
}
|
||||
|
||||
s.hashes.SetWithTTL(b20(hpw), thisCount, 1, hashCacheItemTTL)
|
||||
return thisCount, nil
|
||||
}
|
||||
|
||||
func (s *DefaultPasswordValidator) Validate(ctx context.Context, identifier, password string) error {
|
||||
return otelx.WithSpan(ctx, "password.DefaultPasswordValidator.Validate", func(ctx context.Context) error {
|
||||
return s.validate(ctx, identifier, password)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *DefaultPasswordValidator) validate(ctx context.Context, identifier, password string) error {
|
||||
passwordPolicyConfig := s.reg.Config().PasswordPolicyConfig(ctx)
|
||||
|
||||
//nolint:gosec // disable G115
|
||||
if len(password) < int(passwordPolicyConfig.MinPasswordLength) {
|
||||
//nolint:gosec // disable G115
|
||||
return text.NewErrorValidationPasswordMinLength(int(passwordPolicyConfig.MinPasswordLength), len(password))
|
||||
}
|
||||
|
||||
if passwordPolicyConfig.IdentifierSimilarityCheckEnabled && len(identifier) > 0 &&
|
||||
len(identifier) <= maxIdentifierSimilarityCheckLength &&
|
||||
len(password) <= maxIdentifierSimilarityCheckLength {
|
||||
compIdentifier, compPassword := strings.ToLower(identifier), strings.ToLower(password)
|
||||
// Case-folding can expand an operand, so re-check the folded lengths
|
||||
// before running the O(n·m) comparison.
|
||||
if len(compIdentifier) <= maxIdentifierSimilarityCheckLength &&
|
||||
len(compPassword) <= maxIdentifierSimilarityCheckLength {
|
||||
dist := levenshtein.Distance(compIdentifier, compPassword)
|
||||
lcs := float32(lcsLength(compIdentifier, compPassword)) / float32(len(compPassword))
|
||||
if dist < s.minIdentifierPasswordDist || lcs > s.maxIdentifierPasswordSubstrThreshold {
|
||||
return text.NewErrorValidationPasswordIdentifierTooSimilar()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !passwordPolicyConfig.HaveIBeenPwnedEnabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
//#nosec G401 -- sha1 is used for k-anonymity
|
||||
h := sha1.New()
|
||||
if _, err := h.Write([]byte(password)); err != nil {
|
||||
return err
|
||||
}
|
||||
hpw := h.Sum(nil)
|
||||
|
||||
c, ok := s.hashes.Get(b20(hpw))
|
||||
if !ok {
|
||||
var err error
|
||||
c, err = s.fetch(ctx, hpw, passwordPolicyConfig.HaveIBeenPwnedHost)
|
||||
if (errors.Is(err, ErrNetworkFailure) || errors.Is(err, ErrUnexpectedStatusCode)) && passwordPolicyConfig.IgnoreNetworkErrors {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:gosec // disable G115
|
||||
if c > int64(s.reg.Config().PasswordPolicyConfig(ctx).MaxBreaches) {
|
||||
return text.NewErrorValidationPasswordTooManyBreaches(c)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
31
selfservice/strategy/password/validator_lcs_test.go
Normal file
31
selfservice/strategy/password/validator_lcs_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLCSLength(t *testing.T) {
|
||||
for k, tc := range []struct {
|
||||
a string
|
||||
b string
|
||||
l int
|
||||
}{
|
||||
{a: "foo", b: "foo", l: 3},
|
||||
{a: "fo", b: "foo", l: 2},
|
||||
{a: "bar", b: "foo", l: 0},
|
||||
{a: "foobar", b: "foo", l: 3},
|
||||
{a: "foobar", b: "oo", l: 2},
|
||||
{a: "foobar", b: "a", l: 1},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
|
||||
require.Equal(t, tc.l, lcsLength(tc.a, tc.b))
|
||||
require.Equal(t, tc.l, lcsLength(tc.b, tc.a))
|
||||
})
|
||||
}
|
||||
}
|
||||
469
selfservice/strategy/password/validator_test.go
Normal file
469
selfservice/strategy/password/validator_test.go
Normal file
@@ -0,0 +1,469 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha1" //#nosec G505 -- compatibility for imported passwords
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/kratos/driver"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/kratos/selfservice/strategy/password"
|
||||
"github.com/ory/kratos/text"
|
||||
"github.com/ory/x/configx"
|
||||
"github.com/ory/x/contextx"
|
||||
"github.com/ory/x/httpx"
|
||||
)
|
||||
|
||||
// testRegistry embeds RegistryDefault and overrides HTTPClient to inject a
|
||||
// controllable HTTP client for HIBP-related tests.
|
||||
type testRegistry struct {
|
||||
*driver.RegistryDefault
|
||||
cl *retryablehttp.Client
|
||||
}
|
||||
|
||||
func (r *testRegistry) HTTPClient(_ context.Context, _ ...httpx.ResilientOptions) *retryablehttp.Client {
|
||||
return r.cl
|
||||
}
|
||||
|
||||
// newTestValidator wires base with a fake HIBP HTTP client and returns the
|
||||
// validator along with the fake client for request assertions and stubbing.
|
||||
func newTestValidator(t *testing.T, base *driver.RegistryDefault, opts ...httpx.ResilientOptions) (*password.DefaultPasswordValidator, *fakeHttpClient) {
|
||||
t.Helper()
|
||||
fakeClient := NewFakeHTTPClient()
|
||||
cl := httpx.NewResilientClient(append([]httpx.ResilientOptions{
|
||||
httpx.ResilientClientWithMaxRetry(1),
|
||||
httpx.ResilientClientWithConnectionTimeout(time.Millisecond),
|
||||
}, opts...)...)
|
||||
cl.HTTPClient = &fakeClient.Client
|
||||
s, err := password.NewDefaultPasswordValidatorStrategy(&testRegistry{RegistryDefault: base, cl: cl})
|
||||
require.NoError(t, err)
|
||||
return s, fakeClient
|
||||
}
|
||||
|
||||
func TestDefaultPasswordValidationStrategy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Tests are based on:
|
||||
// - https://www.troyhunt.com/passwords-evolved-authentication-guidance-for-the-modern-era/
|
||||
// - https://www.microsoft.com/en-us/research/wp-content/uploads/2016/06/Microsoft_Password_Guidance-1.pdf
|
||||
|
||||
t.Run("default strategy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
s, err := password.NewDefaultPasswordValidatorStrategy(reg)
|
||||
require.NoError(t, err)
|
||||
|
||||
for k, tc := range []struct {
|
||||
id, pw string
|
||||
expectedErr error
|
||||
}{
|
||||
{pw: "", expectedErr: text.NewErrorValidationPasswordMinLength(0, 0)},
|
||||
{pw: "12", expectedErr: text.NewErrorValidationPasswordMinLength(0, 0)},
|
||||
{pw: "1234", expectedErr: text.NewErrorValidationPasswordMinLength(0, 0)},
|
||||
{pw: "123456", expectedErr: text.NewErrorValidationPasswordMinLength(0, 0)},
|
||||
{pw: "12345678", expectedErr: text.NewErrorValidationPasswordTooManyBreaches(0)},
|
||||
{pw: "password", expectedErr: text.NewErrorValidationPasswordTooManyBreaches(0)},
|
||||
{pw: "1234567890", expectedErr: text.NewErrorValidationPasswordTooManyBreaches(0)},
|
||||
{pw: "qwertyui", expectedErr: text.NewErrorValidationPasswordTooManyBreaches(0)},
|
||||
{pw: "l3f9to", expectedErr: text.NewErrorValidationPasswordMinLength(0, 0)},
|
||||
{pw: "l3f9toh1uaf81n21"},
|
||||
{pw: "l3f9toh1uaf81n21", id: "l3f9toh1uaf81n21", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
|
||||
{pw: "l3f9toh1"},
|
||||
{pw: "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"},
|
||||
// simple permutation tests
|
||||
{id: "hello@example.com", pw: "hello@example.com12345", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
|
||||
{id: "hello@example.com", pw: "123hello@example.com123", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
|
||||
{id: "hello@example.com", pw: "hello@exam", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
|
||||
{id: "hello@example.com", pw: "HELLO@EXAMPLE.COM", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
|
||||
{id: "hello@example.com", pw: "HELLO@example.com", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
|
||||
{pw: "hello@example.com", id: "hello@exam", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
|
||||
{id: "hello@example.com", pw: "h3ll0@example", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
|
||||
{pw: "hello@example.com", id: "hello@exam", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
|
||||
{id: "abcd", pw: "9d3c8a1b"},
|
||||
{id: "a", pw: "kjOklafe"},
|
||||
{id: "ab", pw: "0000ab0000123"},
|
||||
// longest common substring with long password
|
||||
{id: "d4f6090b-5a84", pw: "d4f6090b-5a84-2184-4404-8d1b-8da3eb00ebbe"},
|
||||
{id: "asdflasdflasdf", pw: "asdflasdflpiuhefnciluaksdzuföfhg"},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := s.Validate(t.Context(), tc.id, tc.pw)
|
||||
if tc.expectedErr == nil {
|
||||
require.NoErrorf(t, err, "id: %s, pw: %s", tc.id, tc.pw)
|
||||
} else {
|
||||
require.ErrorIsf(t, err, tc.expectedErr, "id: %s, pw: %s", tc.id, tc.pw)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("failure cases", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, base := pkg.NewFastRegistryWithMocks(t)
|
||||
s, fakeClient := newTestValidator(t, base, httpx.ResilientClientWithMaxRetryWait(time.Millisecond))
|
||||
|
||||
t.Run("case=should send request to pwnedpasswords.com", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, false)
|
||||
require.Error(t, s.Validate(ctx, "mohutdesub", "damrumukuh"))
|
||||
require.Contains(t, fakeClient.RequestedURLs(), "https://api.pwnedpasswords.com/range/BCBA9")
|
||||
})
|
||||
|
||||
t.Run("case=should fail if request fails and ignoreNetworkErrors is not set", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, false)
|
||||
fakeClient.RespondWithError("Network request failed")
|
||||
require.Error(t, s.Validate(ctx, "", "sumdarmetp"))
|
||||
})
|
||||
|
||||
t.Run("case=should not fail if request fails and ignoreNetworkErrors is set", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, true)
|
||||
fakeClient.RespondWithError("Network request failed")
|
||||
require.NoError(t, s.Validate(ctx, "", "pepegtawni"))
|
||||
})
|
||||
|
||||
t.Run("case=should fail if response has non 200 code and ignoreNetworkErrors is not set", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, false)
|
||||
fakeClient.RespondWith(http.StatusForbidden, "")
|
||||
require.Error(t, s.Validate(ctx, "", "jolhakowef"))
|
||||
})
|
||||
|
||||
t.Run("case=should not fail if response has non 200 code code and ignoreNetworkErrors is set", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, true)
|
||||
fakeClient.RespondWith(http.StatusInternalServerError, "")
|
||||
require.NoError(t, s.Validate(ctx, "", "jenuzuhjoj"))
|
||||
})
|
||||
|
||||
t.Run("case=should fail with a network error if reading the response body fails and ignoreNetworkErrors is not set", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, false)
|
||||
fakeClient.RespondWithBodyError("003D68EB55068C33ACE09247EE4C639306B:3\n", errBodyRead)
|
||||
require.ErrorIs(t, s.Validate(ctx, "", "vebjihwoct"), password.ErrNetworkFailure)
|
||||
})
|
||||
|
||||
t.Run("case=should not fail if reading the response body fails and ignoreNetworkErrors is set", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, true)
|
||||
fakeClient.RespondWithBodyError("003D68EB55068C33ACE09247EE4C639306B:3\n", errBodyRead)
|
||||
require.NoError(t, s.Validate(ctx, "", "hufzeqmalt"))
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("max breaches", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const maxBreaches = 5
|
||||
_, base := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeyPasswordMaxBreaches, maxBreaches))
|
||||
s, fakeClient := newTestValidator(t, base)
|
||||
|
||||
hibpResp := make(chan string, 1)
|
||||
fakeClient.responder = func(req *http.Request) (*http.Response, error) {
|
||||
buffer := bytes.NewBufferString(<-hibpResp)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(buffer),
|
||||
ContentLength: int64(buffer.Len()),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
|
||||
hashPw := func(t *testing.T, pw string) string {
|
||||
//#nosec G401 -- sha1 is used for k-anonymity
|
||||
h := sha1.New()
|
||||
_, err := h.Write([]byte(pw))
|
||||
require.NoError(t, err)
|
||||
hpw := h.Sum(nil)
|
||||
return fmt.Sprintf("%X", hpw)[5:]
|
||||
}
|
||||
randomPassword := func(t *testing.T) string {
|
||||
pw := make([]byte, 10)
|
||||
_, err := rand.Read(pw)
|
||||
require.NoError(t, err)
|
||||
return fmt.Sprintf("%x", pw)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
res func(t *testing.T, hash string) string
|
||||
expectErr error
|
||||
}{
|
||||
{
|
||||
name: "contains invalid data which is ignored",
|
||||
res: func(t *testing.T, hash string) string {
|
||||
return fmt.Sprintf("%s:2\ninvalid", hash)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "is missing a colon",
|
||||
res: func(t *testing.T, hash string) string {
|
||||
return hash
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "contains invalid hash count",
|
||||
res: func(t *testing.T, hash string) string {
|
||||
return fmt.Sprintf("%s:text\n%s:2", hashPw(t, randomPassword(t)), hash)
|
||||
},
|
||||
expectErr: herodot.ErrUpstreamError(),
|
||||
},
|
||||
{
|
||||
name: "is missing hash count",
|
||||
res: func(t *testing.T, hash string) string {
|
||||
return fmt.Sprintf("%s\n%s:2", hash, hashPw(t, randomPassword(t)))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "response contains no matches",
|
||||
res: func(t *testing.T, hash string) string {
|
||||
return fmt.Sprintf("%s:57", hashPw(t, randomPassword(t)))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "contains less than maxBreachesThreshold",
|
||||
res: func(t *testing.T, hash string) string {
|
||||
return fmt.Sprintf(
|
||||
"%s:%d\n%s:%d",
|
||||
hash,
|
||||
maxBreaches,
|
||||
hashPw(t, randomPassword(t)),
|
||||
maxBreaches+1,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "contains less than maxBreachesThreshold with a leading comma",
|
||||
res: func(t *testing.T, hash string) string {
|
||||
return fmt.Sprintf(
|
||||
"%s:%d\n%s:0,%d",
|
||||
hash,
|
||||
maxBreaches,
|
||||
hashPw(t, randomPassword(t)),
|
||||
maxBreaches+1,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "contains more than maxBreachesThreshold",
|
||||
res: func(t *testing.T, hash string) string {
|
||||
return fmt.Sprintf("%s:%d", hash, maxBreaches+1)
|
||||
},
|
||||
expectErr: text.NewErrorValidationPasswordTooManyBreaches(
|
||||
int64(maxBreaches) + 1, // #nosec G115
|
||||
),
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%s/expected err=%s", tc.name, tc.expectErr), func(t *testing.T) {
|
||||
pw := randomPassword(t)
|
||||
hash := hashPw(t, pw)
|
||||
hibpResp <- tc.res(t, hash)
|
||||
|
||||
err := s.Validate(context.Background(), "", pw)
|
||||
assert.ErrorIs(t, err, tc.expectErr)
|
||||
})
|
||||
|
||||
// verify the fetch was done, i.e. channel is empty
|
||||
select {
|
||||
case r := <-hibpResp:
|
||||
t.Logf("expected the validate step to fetch the response, but I still got %s", r)
|
||||
t.FailNow()
|
||||
default:
|
||||
// continue
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestChangeHaveIBeenPwnedValidationHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||
t.Cleanup(testServer.Close)
|
||||
testServerURL, err := url.Parse(testServer.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, base := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeyPasswordHaveIBeenPwnedHost, testServerURL.Host))
|
||||
s, fakeClient := newTestValidator(t, base)
|
||||
|
||||
testServerExpectedCallURL := fmt.Sprintf("https://%s/range/BCBA9", testServerURL.Host)
|
||||
|
||||
t.Run("case=should send request to test server", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, false)
|
||||
require.Error(t, s.Validate(ctx, "mohutdesub", "damrumukuh"))
|
||||
require.Contains(t, fakeClient.RequestedURLs(), testServerExpectedCallURL)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDisableHaveIBeenPwnedValidationHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, base := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeyPasswordHaveIBeenPwnedEnabled, false))
|
||||
s, fakeClient := newTestValidator(t, base)
|
||||
|
||||
t.Run("case=should not send request to test server", func(t *testing.T) {
|
||||
require.NoError(t, s.Validate(t.Context(), "mohutdesub", "damrumukuh"))
|
||||
require.Empty(t, fakeClient.RequestedURLs())
|
||||
})
|
||||
}
|
||||
|
||||
func TestChangeMinPasswordLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeyPasswordMinLength, 10))
|
||||
s, err := password.NewDefaultPasswordValidatorStrategy(reg)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("case=should not fail if password is longer than min length", func(t *testing.T) {
|
||||
require.NoError(t, s.Validate(t.Context(), "", "kuobahcaas"))
|
||||
})
|
||||
|
||||
t.Run("case=should fail if password is shorter than min length", func(t *testing.T) {
|
||||
require.Error(t, s.Validate(t.Context(), "", "rfqyfjied"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestChangeIdentifierSimilarityCheckEnabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
s, err := password.NewDefaultPasswordValidatorStrategy(reg)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("case=should not fail if password is similar to identifier", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyPasswordIdentifierSimilarityCheckEnabled, false)
|
||||
require.NoError(t, s.Validate(ctx, "bosqwfaxee", "bosqwfaxee"))
|
||||
})
|
||||
|
||||
t.Run("case=should fail if password is similar to identifier", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyPasswordIdentifierSimilarityCheckEnabled, true)
|
||||
require.Error(t, s.Validate(ctx, "bosqwfaxee", "bosqwfaxee"))
|
||||
})
|
||||
}
|
||||
|
||||
// TestIdentifierSimilarityCheckMaxLength guards against an algorithmic-complexity
|
||||
// DoS (H1 #3866310): the O(n·m) identifier-similarity check must skip oversized
|
||||
// operands, which a short identifier can never be similar to anyway.
|
||||
func TestIdentifierSimilarityCheckMaxLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Disable HaveIBeenPwned so validation is deterministic and offline.
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeyPasswordHaveIBeenPwnedEnabled, false))
|
||||
s, err := password.NewDefaultPasswordValidatorStrategy(reg)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyPasswordIdentifierSimilarityCheckEnabled, true)
|
||||
|
||||
t.Run("case=skips the similarity check for oversized operands", func(t *testing.T) {
|
||||
// Two identical strings one char past the 256-char cap would trip the
|
||||
// too-similar check if it ran, so a nil result proves the length guard
|
||||
// skipped it. Staying just over the cap keeps the test cheap even if the
|
||||
// guard regresses (the check allocates an O(n·m) matrix).
|
||||
large := strings.Repeat("a", 257)
|
||||
require.NoError(t, s.Validate(ctx, large, large))
|
||||
})
|
||||
|
||||
t.Run("case=still detects similarity for short operands", func(t *testing.T) {
|
||||
require.ErrorIs(t, s.Validate(ctx, "bosqwfaxee", "bosqwfaxee"), text.NewErrorValidationPasswordIdentifierTooSimilar())
|
||||
})
|
||||
}
|
||||
|
||||
type fakeHttpClient struct {
|
||||
http.Client
|
||||
|
||||
requestedURLs []string
|
||||
responder func(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func NewFakeHTTPClient() *fakeHttpClient {
|
||||
client := fakeHttpClient{
|
||||
responder: func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("No responder defined in fake HTTP client")
|
||||
},
|
||||
}
|
||||
client.Client = http.Client{
|
||||
Transport: &fakeRoundTripper{&client},
|
||||
Timeout: time.Second,
|
||||
}
|
||||
return &client
|
||||
}
|
||||
|
||||
func (c *fakeHttpClient) RespondWith(status int, body string) {
|
||||
c.responder = func(request *http.Request) (*http.Response, error) {
|
||||
buffer := bytes.NewBufferString(body)
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Body: io.NopCloser(buffer),
|
||||
ContentLength: int64(buffer.Len()),
|
||||
Request: request,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *fakeHttpClient) RespondWithError(err string) {
|
||||
c.responder = func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New(err)
|
||||
}
|
||||
}
|
||||
|
||||
// RespondWithBodyError responds with a status 200 whose body yields prefix and
|
||||
// then fails, mimicking a connection reset or a client timeout that only hits
|
||||
// once the response is already being read.
|
||||
func (c *fakeHttpClient) RespondWithBodyError(prefix string, err error) {
|
||||
c.responder = func(request *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(io.MultiReader(strings.NewReader(prefix), errorReader{err})),
|
||||
Request: request,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *fakeHttpClient) Reset() {
|
||||
c.requestedURLs = nil
|
||||
}
|
||||
|
||||
func (c *fakeHttpClient) RequestedURLs() []string {
|
||||
return c.requestedURLs
|
||||
}
|
||||
|
||||
func (c *fakeHttpClient) handle(request *http.Request) (*http.Response, error) {
|
||||
c.requestedURLs = append(c.requestedURLs, request.URL.String())
|
||||
if request.Body != nil {
|
||||
_ = request.Body.Close()
|
||||
}
|
||||
return c.responder(request)
|
||||
}
|
||||
|
||||
type fakeRoundTripper struct {
|
||||
client *fakeHttpClient
|
||||
}
|
||||
|
||||
func (rt *fakeRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return rt.client.handle(request)
|
||||
}
|
||||
|
||||
// errBodyRead is the error net/http returns from a response body read when the
|
||||
// client's timeout fires after the response headers have been received.
|
||||
var errBodyRead = errors.New("net/http: request canceled (Client.Timeout or context cancellation while reading body)")
|
||||
|
||||
type errorReader struct{ err error }
|
||||
|
||||
func (r errorReader) Read([]byte) (int, error) { return 0, r.err }
|
||||
Reference in New Issue
Block a user