From 88594075b270e0fd73f6c132b2fbb7e8290aef40 Mon Sep 17 00:00:00 2001 From: Jens L Date: Mon, 14 Nov 2022 14:42:43 +0100 Subject: [PATCH] policies/password: merge hibp add zxcvbn (#4001) * initial zxcvbn Signed-off-by: Jens Langhammer * add api and port tests Signed-off-by: Jens Langhammer * more tests Signed-off-by: Jens Langhammer * add ui Signed-off-by: Jens Langhammer * update docs Signed-off-by: Jens Langhammer * add api diff Signed-off-by: Jens Langhammer Signed-off-by: Jens Langhammer --- Makefile | 2 +- authentik/policies/hibp/models.py | 2 +- authentik/policies/password/api.py | 5 + ...policy_check_have_i_been_pwned_and_more.py | 73 ++ authentik/policies/password/models.py | 106 ++- .../policies/password/tests/test_hibp.py | 50 ++ .../policies/password/tests/test_zxcvbn.py | 50 ++ poetry.lock | 13 +- pyproject.toml | 9 +- schema.yml | 71 ++ .../policies/password/PasswordPolicyForm.ts | 367 +++++++--- .../providers/proxy/ProxyProviderForm.ts | 11 +- website/docs/policies/index.md | 8 + website/docs/releases/v2022.11.md | 693 +++++++++++++++++- 14 files changed, 1310 insertions(+), 150 deletions(-) create mode 100644 authentik/policies/password/migrations/0005_passwordpolicy_check_have_i_been_pwned_and_more.py create mode 100644 authentik/policies/password/tests/test_hibp.py create mode 100644 authentik/policies/password/tests/test_zxcvbn.py diff --git a/Makefile b/Makefile index cdbbf63f2..03901c165 100644 --- a/Makefile +++ b/Makefile @@ -69,7 +69,7 @@ gen-build: AUTHENTIK_DEBUG=true ak spectacular --file schema.yml gen-diff: - git show $(shell git tag -l | tail -n 1):schema.yml > old_schema.yml + git show $(shell git describe --abbrev=0):schema.yml > old_schema.yml docker run \ --rm -v ${PWD}:/local \ --user ${UID}:${GID} \ diff --git a/authentik/policies/hibp/models.py b/authentik/policies/hibp/models.py index 6123a775d..0ffb20e2c 100644 --- a/authentik/policies/hibp/models.py +++ b/authentik/policies/hibp/models.py @@ -15,7 +15,7 @@ LOGGER = get_logger() class HaveIBeenPwendPolicy(Policy): - """Check if password is on HaveIBeenPwned's list by uploading the first + """DEPRECATED. Check if password is on HaveIBeenPwned's list by uploading the first 5 characters of the SHA1 Hash.""" password_field = models.TextField( diff --git a/authentik/policies/password/api.py b/authentik/policies/password/api.py index 227162e0e..c42a0fee1 100644 --- a/authentik/policies/password/api.py +++ b/authentik/policies/password/api.py @@ -20,6 +20,11 @@ class PasswordPolicySerializer(PolicySerializer): "length_min", "symbol_charset", "error_message", + "check_static_rules", + "check_have_i_been_pwned", + "check_zxcvbn", + "hibp_allowed_count", + "zxcvbn_score_threshold", ] diff --git a/authentik/policies/password/migrations/0005_passwordpolicy_check_have_i_been_pwned_and_more.py b/authentik/policies/password/migrations/0005_passwordpolicy_check_have_i_been_pwned_and_more.py new file mode 100644 index 000000000..58aded95a --- /dev/null +++ b/authentik/policies/password/migrations/0005_passwordpolicy_check_have_i_been_pwned_and_more.py @@ -0,0 +1,73 @@ +# Generated by Django 4.1.3 on 2022-11-14 09:23 +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def migrate_hibp_policy(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + db_alias = schema_editor.connection.alias + + HaveIBeenPwendPolicy = apps.get_model("authentik_policies_hibp", "HaveIBeenPwendPolicy") + PasswordPolicy = apps.get_model("authentik_policies_password", "PasswordPolicy") + + PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding") + + for old_policy in HaveIBeenPwendPolicy.objects.using(db_alias).all(): + new_policy = PasswordPolicy.objects.using(db_alias).create( + name=old_policy.name, + hibp_allowed_count=old_policy.allowed_count, + password_field=old_policy.password_field, + execution_logging=old_policy.execution_logging, + check_static_rules=False, + check_have_i_been_pwned=True, + ) + PolicyBinding.objects.using(db_alias).filter(policy=old_policy).update(policy=new_policy) + old_policy.delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_policies_hibp", "0003_haveibeenpwendpolicy_authentik_p_policy__6957d7_idx"), + ("authentik_policies_password", "0004_passwordpolicy_authentik_p_policy__855e80_idx"), + ] + + operations = [ + migrations.AddField( + model_name="passwordpolicy", + name="check_have_i_been_pwned", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="passwordpolicy", + name="check_static_rules", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="passwordpolicy", + name="check_zxcvbn", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="passwordpolicy", + name="hibp_allowed_count", + field=models.PositiveIntegerField( + default=0, + help_text="How many times the password hash is allowed to be on haveibeenpwned", + ), + ), + migrations.AddField( + model_name="passwordpolicy", + name="zxcvbn_score_threshold", + field=models.PositiveIntegerField( + default=2, + help_text="If the zxcvbn score is equal or less than this value, the policy will fail.", + ), + ), + migrations.AlterField( + model_name="passwordpolicy", + name="error_message", + field=models.TextField(blank=True), + ), + migrations.RunPython(migrate_hibp_policy), + ] diff --git a/authentik/policies/password/models.py b/authentik/policies/password/models.py index 640eaf8c5..fed63464f 100644 --- a/authentik/policies/password/models.py +++ b/authentik/policies/password/models.py @@ -1,11 +1,14 @@ -"""user field matcher models""" +"""password policy""" import re +from hashlib import sha1 from django.db import models from django.utils.translation import gettext as _ from rest_framework.serializers import BaseSerializer from structlog.stdlib import get_logger +from zxcvbn import zxcvbn +from authentik.lib.utils.http import get_http_session from authentik.policies.models import Policy from authentik.policies.types import PolicyRequest, PolicyResult from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT @@ -24,13 +27,27 @@ class PasswordPolicy(Policy): help_text=_("Field key to check, field keys defined in Prompt stages are available."), ) + check_static_rules = models.BooleanField(default=True) + check_have_i_been_pwned = models.BooleanField(default=False) + check_zxcvbn = models.BooleanField(default=False) + amount_digits = models.PositiveIntegerField(default=0) amount_uppercase = models.PositiveIntegerField(default=0) amount_lowercase = models.PositiveIntegerField(default=0) amount_symbols = models.PositiveIntegerField(default=0) length_min = models.PositiveIntegerField(default=0) symbol_charset = models.TextField(default=r"!\"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ ") - error_message = models.TextField() + error_message = models.TextField(blank=True) + + hibp_allowed_count = models.PositiveIntegerField( + default=0, + help_text=_("How many times the password hash is allowed to be on haveibeenpwned"), + ) + + zxcvbn_score_threshold = models.PositiveIntegerField( + default=2, + help_text=_("If the zxcvbn score is equal or less than this value, the policy will fail."), + ) @property def serializer(self) -> type[BaseSerializer]: @@ -42,48 +59,103 @@ class PasswordPolicy(Policy): def component(self) -> str: return "ak-policy-password-form" - # pylint: disable=too-many-return-statements def passes(self, request: PolicyRequest) -> PolicyResult: - if ( - self.password_field not in request.context - and self.password_field not in request.context.get(PLAN_CONTEXT_PROMPT, {}) - ): + password = request.context.get(PLAN_CONTEXT_PROMPT, {}).get( + self.password_field, request.context.get(self.password_field) + ) + if not password: LOGGER.warning( "Password field not set in Policy Request", field=self.password_field, fields=request.context.keys(), - prompt_fields=request.context.get(PLAN_CONTEXT_PROMPT, {}).keys(), ) return PolicyResult(False, _("Password not set in context")) + password = str(password) - if self.password_field in request.context: - password = request.context[self.password_field] - else: - password = request.context[PLAN_CONTEXT_PROMPT][self.password_field] + if self.check_static_rules: + static_result = self.passes_static(password, request) + if not static_result.passing: + return static_result + if self.check_have_i_been_pwned: + hibp_result = self.passes_hibp(password, request) + if not hibp_result.passing: + return hibp_result + if self.check_zxcvbn: + zxcvbn_result = self.passes_zxcvbn(password, request) + if not zxcvbn_result.passing: + return zxcvbn_result + return PolicyResult(True) + # pylint: disable=too-many-return-statements + def passes_static(self, password: str, request: PolicyRequest) -> PolicyResult: + """Check static rules""" if len(password) < self.length_min: - LOGGER.debug("password failed", reason="length") + LOGGER.debug("password failed", check="static", reason="length") return PolicyResult(False, self.error_message) if self.amount_digits > 0 and len(RE_DIGITS.findall(password)) < self.amount_digits: - LOGGER.debug("password failed", reason="amount_digits") + LOGGER.debug("password failed", check="static", reason="amount_digits") return PolicyResult(False, self.error_message) if self.amount_lowercase > 0 and len(RE_LOWER.findall(password)) < self.amount_lowercase: - LOGGER.debug("password failed", reason="amount_lowercase") + LOGGER.debug("password failed", check="static", reason="amount_lowercase") return PolicyResult(False, self.error_message) if self.amount_uppercase > 0 and len(RE_UPPER.findall(password)) < self.amount_lowercase: - LOGGER.debug("password failed", reason="amount_uppercase") + LOGGER.debug("password failed", check="static", reason="amount_uppercase") return PolicyResult(False, self.error_message) if self.amount_symbols > 0: count = 0 for symbol in self.symbol_charset: count += password.count(symbol) if count < self.amount_symbols: - LOGGER.debug("password failed", reason="amount_symbols") + LOGGER.debug("password failed", check="static", reason="amount_symbols") return PolicyResult(False, self.error_message) return PolicyResult(True) + def check_hibp(self, short_hash: str) -> str: + """Check the haveibeenpwned API""" + url = f"https://api.pwnedpasswords.com/range/{short_hash}" + return get_http_session().get(url).text + + def passes_hibp(self, password: str, request: PolicyRequest) -> PolicyResult: + """Check if password is in HIBP DB. Hashes given Password with SHA1, uses the first 5 + characters of Password in request and checks if full hash is in response. Returns 0 + if Password is not in result otherwise the count of how many times it was used.""" + pw_hash = sha1(password.encode("utf-8")).hexdigest() # nosec + result = self.check_hibp(pw_hash[:5]) + final_count = 0 + for line in result.split("\r\n"): + full_hash, count = line.split(":") + if pw_hash[5:] == full_hash.lower(): + final_count = int(count) + LOGGER.debug("got hibp result", count=final_count, hash=pw_hash[:5]) + if final_count > self.hibp_allowed_count: + LOGGER.debug("password failed", check="hibp", count=final_count) + message = _("Password exists on %(count)d online lists." % {"count": final_count}) + return PolicyResult(False, message) + return PolicyResult(True) + + def passes_zxcvbn(self, password: str, request: PolicyRequest) -> PolicyResult: + """Check Dropbox's zxcvbn password estimator""" + user_inputs = [] + if request.user.is_authenticated: + user_inputs.append(request.user.username) + user_inputs.append(request.user.name) + user_inputs.append(request.user.email) + if request.http_request: + user_inputs.append(request.http_request.tenant.branding_title) + # Only calculate result for the first 100 characters, as with over 100 char + # long passwords we can be reasonably sure that they'll surpass the score anyways + # See https://github.com/dropbox/zxcvbn#runtime-latency + results = zxcvbn(password[:100], user_inputs) + LOGGER.debug("password failed", check="zxcvbn", score=results["score"]) + result = PolicyResult(results["score"] > self.zxcvbn_score_threshold) + if isinstance(results["feedback"]["warning"], list): + result.messages += tuple(results["feedback"]["warning"]) + if isinstance(results["feedback"]["suggestions"], list): + result.messages += tuple(results["feedback"]["suggestions"]) + return result + class Meta(Policy.PolicyMeta): verbose_name = _("Password Policy") diff --git a/authentik/policies/password/tests/test_hibp.py b/authentik/policies/password/tests/test_hibp.py new file mode 100644 index 000000000..9ffd3b0d6 --- /dev/null +++ b/authentik/policies/password/tests/test_hibp.py @@ -0,0 +1,50 @@ +"""Password Policy HIBP tests""" +from django.test import TestCase +from guardian.shortcuts import get_anonymous_user + +from authentik.lib.generators import generate_key +from authentik.policies.password.models import PasswordPolicy +from authentik.policies.types import PolicyRequest, PolicyResult +from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT + + +class TestPasswordPolicyHIBP(TestCase): + """Test Password Policy (haveibeenpwned)""" + + def test_invalid(self): + """Test without password""" + policy = PasswordPolicy.objects.create( + check_have_i_been_pwned=True, + check_static_rules=False, + name="test_invalid", + ) + request = PolicyRequest(get_anonymous_user()) + result: PolicyResult = policy.passes(request) + self.assertFalse(result.passing) + self.assertEqual(result.messages[0], "Password not set in context") + + def test_false(self): + """Failing password case""" + policy = PasswordPolicy.objects.create( + check_have_i_been_pwned=True, + check_static_rules=False, + name="test_false", + ) + request = PolicyRequest(get_anonymous_user()) + request.context[PLAN_CONTEXT_PROMPT] = {"password": "password"} # nosec + result: PolicyResult = policy.passes(request) + self.assertFalse(result.passing) + self.assertTrue(result.messages[0].startswith("Password exists on ")) + + def test_true(self): + """Positive password case""" + policy = PasswordPolicy.objects.create( + check_have_i_been_pwned=True, + check_static_rules=False, + name="test_true", + ) + request = PolicyRequest(get_anonymous_user()) + request.context[PLAN_CONTEXT_PROMPT] = {"password": generate_key()} + result: PolicyResult = policy.passes(request) + self.assertTrue(result.passing) + self.assertEqual(result.messages, tuple()) diff --git a/authentik/policies/password/tests/test_zxcvbn.py b/authentik/policies/password/tests/test_zxcvbn.py new file mode 100644 index 000000000..7d03ba841 --- /dev/null +++ b/authentik/policies/password/tests/test_zxcvbn.py @@ -0,0 +1,50 @@ +"""Password Policy zxcvbn tests""" +from django.test import TestCase +from guardian.shortcuts import get_anonymous_user + +from authentik.lib.generators import generate_key +from authentik.policies.password.models import PasswordPolicy +from authentik.policies.types import PolicyRequest, PolicyResult +from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT + + +class TestPasswordPolicyZxcvbn(TestCase): + """Test Password Policy (zxcvbn)""" + + def test_invalid(self): + """Test without password""" + policy = PasswordPolicy.objects.create( + check_zxcvbn=True, + check_static_rules=False, + name="test_invalid", + ) + request = PolicyRequest(get_anonymous_user()) + result: PolicyResult = policy.passes(request) + self.assertFalse(result.passing) + self.assertEqual(result.messages[0], "Password not set in context") + + def test_false(self): + """Failing password case""" + policy = PasswordPolicy.objects.create( + check_zxcvbn=True, + check_static_rules=False, + name="test_false", + ) + request = PolicyRequest(get_anonymous_user()) + request.context[PLAN_CONTEXT_PROMPT] = {"password": "password"} # nosec + result: PolicyResult = policy.passes(request) + self.assertFalse(result.passing, result.messages) + self.assertEqual(result.messages[0], "Add another word or two. Uncommon words are better.") + + def test_true(self): + """Positive password case""" + policy = PasswordPolicy.objects.create( + check_zxcvbn=True, + check_static_rules=False, + name="test_true", + ) + request = PolicyRequest(get_anonymous_user()) + request.context[PLAN_CONTEXT_PROMPT] = {"password": generate_key()} + result: PolicyResult = policy.passes(request) + self.assertTrue(result.passing) + self.assertEqual(result.messages, tuple()) diff --git a/poetry.lock b/poetry.lock index a4eec995c..325e1ad3d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2113,10 +2113,18 @@ docs = ["Sphinx", "repoze.sphinx.autointerface"] test = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] +[[package]] +name = "zxcvbn" +version = "4.4.28" +description = "" +category = "main" +optional = false +python-versions = "*" + [metadata] lock-version = "1.1" python-versions = "^3.11" -content-hash = "2250e4c5173156b62a2b417e8d8ec895135142b4d176a696cd7fb9f732b5b129" +content-hash = "7a0fe2bd1d710517a961731f78a2cf2e9d70c277d208606c56d765947e529dca" [metadata.files] aiohttp = [ @@ -3921,3 +3929,6 @@ zope-interface = [ {file = "zope.interface-5.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:8343536ea4ee15d6525e3e726bb49ffc3f2034f828a49237a36be96842c06e7c"}, {file = "zope.interface-5.5.1.tar.gz", hash = "sha256:6d678475fdeb11394dc9aaa5c564213a1567cc663082e0ee85d52f78d1fbaab2"}, ] +zxcvbn = [ + {file = "zxcvbn-4.4.28.tar.gz", hash = "sha256:151bd816817e645e9064c354b13544f85137ea3320ca3be1fb6873ea75ef7dc1"}, +] diff --git a/pyproject.toml b/pyproject.toml index 86c256965..e547d5778 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -124,13 +124,16 @@ djangorestframework = "*" djangorestframework-guardian = "*" docker = "*" drf-spectacular = "*" +dumb-init = "*" duo-client = "*" facebook-sdk = "*" +flower = "*" geoip2 = "*" gunicorn = "*" kubernetes = "*" ldap3 = "*" lxml = "*" +opencontainers = {extras = ["reggie"],version = "*"} packaging = "*" paramiko = "*" psycopg2-binary = "*" @@ -143,6 +146,7 @@ sentry-sdk = "*" service_identity = "*" structlog = "*" swagger-spec-validator = "*" +twilio = "*" twisted = "*" ua-parser = "*" urllib3 = {extras = ["secure"],version = "*"} @@ -150,10 +154,7 @@ uvicorn = {extras = ["standard"],version = "*"} webauthn = "*" wsproto = "*" xmlsec = "*" -twilio = "*" -dumb-init = "*" -flower = "*" -opencontainers = {extras = ["reggie"],version = "*"} +zxcvbn = "*" [tool.poetry.dev-dependencies] bandit = "*" diff --git a/schema.yml b/schema.yml index 7f4693e6d..45442d543 100644 --- a/schema.yml +++ b/schema.yml @@ -11434,6 +11434,18 @@ paths: name: amount_uppercase schema: type: integer + - in: query + name: check_have_i_been_pwned + schema: + type: boolean + - in: query + name: check_static_rules + schema: + type: boolean + - in: query + name: check_zxcvbn + schema: + type: boolean - in: query name: created schema: @@ -11447,6 +11459,10 @@ paths: name: execution_logging schema: type: boolean + - in: query + name: hibp_allowed_count + schema: + type: integer - in: query name: last_updated schema: @@ -11497,6 +11513,10 @@ paths: name: symbol_charset schema: type: string + - in: query + name: zxcvbn_score_threshold + schema: + type: integer tags: - policies security: @@ -32889,6 +32909,23 @@ components: type: string error_message: type: string + check_static_rules: + type: boolean + check_have_i_been_pwned: + type: boolean + check_zxcvbn: + type: boolean + hibp_allowed_count: + type: integer + maximum: 2147483647 + minimum: 0 + description: How many times the password hash is allowed to be on haveibeenpwned + zxcvbn_score_threshold: + type: integer + maximum: 2147483647 + minimum: 0 + description: If the zxcvbn score is equal or less than this value, the policy + will fail. required: - bound_to - component @@ -32939,6 +32976,23 @@ components: error_message: type: string minLength: 1 + check_static_rules: + type: boolean + check_have_i_been_pwned: + type: boolean + check_zxcvbn: + type: boolean + hibp_allowed_count: + type: integer + maximum: 2147483647 + minimum: 0 + description: How many times the password hash is allowed to be on haveibeenpwned + zxcvbn_score_threshold: + type: integer + maximum: 2147483647 + minimum: 0 + description: If the zxcvbn score is equal or less than this value, the policy + will fail. required: - error_message PasswordStage: @@ -34201,6 +34255,23 @@ components: error_message: type: string minLength: 1 + check_static_rules: + type: boolean + check_have_i_been_pwned: + type: boolean + check_zxcvbn: + type: boolean + hibp_allowed_count: + type: integer + maximum: 2147483647 + minimum: 0 + description: How many times the password hash is allowed to be on haveibeenpwned + zxcvbn_score_threshold: + type: integer + maximum: 2147483647 + minimum: 0 + description: If the zxcvbn score is equal or less than this value, the policy + will fail. PatchedPasswordStageRequest: type: object description: PasswordStage Serializer diff --git a/web/src/admin/policies/password/PasswordPolicyForm.ts b/web/src/admin/policies/password/PasswordPolicyForm.ts index 884e38121..3b12ad79b 100644 --- a/web/src/admin/policies/password/PasswordPolicyForm.ts +++ b/web/src/admin/policies/password/PasswordPolicyForm.ts @@ -7,17 +7,33 @@ import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import { t } from "@lingui/macro"; import { TemplateResult, html } from "lit"; -import { customElement } from "lit/decorators.js"; +import { customElement, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { PasswordPolicy, PoliciesApi } from "@goauthentik/api"; @customElement("ak-policy-password-form") export class PasswordPolicyForm extends ModelForm { + @state() + showStatic = true; + + @state() + showHIBP = false; + + @state() + showZxcvbn = false; + loadInstance(pk: string): Promise { - return new PoliciesApi(DEFAULT_CONFIG).policiesPasswordRetrieve({ - policyUuid: pk, - }); + return new PoliciesApi(DEFAULT_CONFIG) + .policiesPasswordRetrieve({ + policyUuid: pk, + }) + .then((policy) => { + this.showStatic = policy.checkStaticRules || false; + this.showHIBP = policy.checkHaveIBeenPwned || false; + this.showZxcvbn = policy.checkZxcvbn || false; + return policy; + }); } getSuccessMessage(): string { @@ -41,6 +57,168 @@ export class PasswordPolicyForm extends ModelForm { } }; + renderStaticRules(): TemplateResult { + return html` + ${t`Static rules`} +
+ + + + + + + + + + + + + + + + + + + + ?@[]^_`{|}~ ", + )}" + class="pf-c-form-control" + required + /> +

+ ${t`Characters which are considered as symbols.`} +

+
+
+
`; + } + + renderHIBP(): TemplateResult { + return html` + + ${t`HaveIBeenPwned settings`} +
+ + +

+ ${t`Allow up to N occurrences in the HIBP database.`} +

+
+
+
+ `; + } + + renderZxcvbn(): TemplateResult { + return html` + + ${t`zxcvbn settings`} +
+ + +

+ ${t`If the password's score is less than or equal this value, the policy will fail.`} +

+

+ ${t`0: Too guessable: risky password. (guesses < 10^3)`} +

+

+ ${t`1: Very guessable: protection from throttled online attacks. (guesses < 10^6)`} +

+

+ ${t`2: Somewhat guessable: protection from unthrottled online attacks. (guesses < 10^8)`} +

+

+ ${t`3: Safely unguessable: moderate protection from offline slow-hash scenario. (guesses < 10^10)`} +

+

+ ${t`4: Very unguessable: strong protection from offline slow-hash scenario. (guesses >= 10^10)`} +

+
+
+
+ `; + } + renderForm(): TemplateResult { return html`
@@ -67,122 +245,77 @@ export class PasswordPolicyForm extends ModelForm { ${t`When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged.`}

- - ${t`Policy-specific settings`} -
- - -

- ${t`Field key to check, field keys defined in Prompt stages are available.`} -

-
+ + +

+ ${t`Field key to check, field keys defined in Prompt stages are available.`} +

+
- - - - - - - - - - - - - - - - - - + +
+ { + const el = ev.target as HTMLInputElement; + this.showStatic = el.checked; + }} + /> +
- - - ${t`Advanced settings`} -
- - ?@[]^_`{|}~ ", - )}" - class="pf-c-form-control" - required - /> -

- ${t`Characters which are considered as symbols.`} -

-
+ + +
+ { + const el = ev.target as HTMLInputElement; + this.showHIBP = el.checked; + }} + /> +
- +

+ ${t`For more info see:`} + haveibeenpwned.com +

+
+ +
+ { + const el = ev.target as HTMLInputElement; + this.showZxcvbn = el.checked; + }} + /> + +
+

+ ${t`Password strength estimator created by Dropbox, see:`} + dropbox/zxcvbn +

+
+ ${this.showStatic ? this.renderStaticRules() : html``} + ${this.showHIBP ? this.renderHIBP() : html``} + ${this.showZxcvbn ? this.renderZxcvbn() : html``} `; } } diff --git a/web/src/admin/providers/proxy/ProxyProviderForm.ts b/web/src/admin/providers/proxy/ProxyProviderForm.ts index a4d6af167..a47d56708 100644 --- a/web/src/admin/providers/proxy/ProxyProviderForm.ts +++ b/web/src/admin/providers/proxy/ProxyProviderForm.ts @@ -9,7 +9,7 @@ import { t } from "@lingui/macro"; import { CSSResult, css } from "lit"; import { TemplateResult, html } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { customElement, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { until } from "lit/directives/until.js"; @@ -56,10 +56,10 @@ export class ProxyProviderFormPage extends ModelForm { }); } - @property({ type: Boolean }) + @state() showHttpBasic = true; - @property({ attribute: false }) + @state() mode: ProxyMode = ProxyMode.Proxy; getSuccessMessage(): string { @@ -85,9 +85,6 @@ export class ProxyProviderFormPage extends ModelForm { }; renderHttpBasic(): TemplateResult { - if (!this.showHttpBasic) { - return html``; - } return html` - ${this.renderHttpBasic()} + ${this.showHttpBasic ? this.renderHttpBasic() : html``}
`; diff --git a/website/docs/policies/index.md b/website/docs/policies/index.md index 5dc3e9eee..60b20d9b9 100644 --- a/website/docs/policies/index.md +++ b/website/docs/policies/index.md @@ -12,6 +12,9 @@ See [Expression Policy](expression.mdx). ## Have I Been Pwned Policy +:::info +This policy is deprecated since authentik 2022.11.0, as this can be done with the password policy now. +::: This policy checks the hashed password against the [Have I Been Pwned](https://haveibeenpwned.com/) API. This only sends the first 5 characters of the hashed password. The remaining comparison is done within authentik. ## Password-Expiry Policy @@ -29,6 +32,11 @@ The following rules can be set: - Minimum length. - Symbol charset (define which characters are counted as symbols). +Starting with authentik 2022.11.0, the following checks can also be done with this policy: + +- Check the password hash against the database of [Have I Been Pwned](https://haveibeenpwned.com/). Only the first 5 characters of the hashed password are transmitted, the rest is compared in authentik +- Check the password against the password complexity checker [zxcvbn](https://github.com/dropbox/zxcvbn), which detects weak password on various metrics. + ## Reputation Policy authentik keeps track of failed login attempts by source IP and attempted username. These values are saved as scores. Each failed login decreases the score for the client IP as well as the targeted username by 1 (one). diff --git a/website/docs/releases/v2022.11.md b/website/docs/releases/v2022.11.md index 68926079b..ffcb67bc9 100644 --- a/website/docs/releases/v2022.11.md +++ b/website/docs/releases/v2022.11.md @@ -5,13 +5,702 @@ slug: "2022.11" ## Breaking changes -- authentik now runs on Python 3.11 +- Have I Been Pwned policy is deprecated + + The policy has been merged with the password policy which provides the same functionality. Existing Have I Been Pwned policies will automatically be migrated. ## New features +- authentik now runs on Python 3.11 +- Expanded password policy + + The "Have I been Pwned" policy has been merged into the password policy, and additionally passwords can be checked using [zxcvbn](https://github.com/dropbox/zxcvbn) to provider concise feedback. + ## API Changes -_Insert output of `make gen-diff` here_ +#### What's Changed + +--- + +##### `GET` /policies/password/{policy_uuid}/ + +###### Return Type: + +Changed response : **200 OK** + +- Changed content type : `application/json` + + - Added property `check_static_rules` (boolean) + + - Added property `check_have_i_been_pwned` (boolean) + + - Added property `check_zxcvbn` (boolean) + + - Added property `hibp_allowed_count` (integer) + + > How many times the password hash is allowed to be on haveibeenpwned + + - Added property `zxcvbn_score_threshold` (integer) + > If the zxcvbn score is equal or less than this value, the policy will fail. + +##### `PUT` /policies/password/{policy_uuid}/ + +###### Request: + +Changed content type : `application/json` + +- Added property `check_static_rules` (boolean) + +- Added property `check_have_i_been_pwned` (boolean) + +- Added property `check_zxcvbn` (boolean) + +- Added property `hibp_allowed_count` (integer) + + > How many times the password hash is allowed to be on haveibeenpwned + +- Added property `zxcvbn_score_threshold` (integer) + > If the zxcvbn score is equal or less than this value, the policy will fail. + +###### Return Type: + +Changed response : **200 OK** + +- Changed content type : `application/json` + + - Added property `check_static_rules` (boolean) + + - Added property `check_have_i_been_pwned` (boolean) + + - Added property `check_zxcvbn` (boolean) + + - Added property `hibp_allowed_count` (integer) + + > How many times the password hash is allowed to be on haveibeenpwned + + - Added property `zxcvbn_score_threshold` (integer) + > If the zxcvbn score is equal or less than this value, the policy will fail. + +##### `PATCH` /policies/password/{policy_uuid}/ + +###### Request: + +Changed content type : `application/json` + +- Added property `check_static_rules` (boolean) + +- Added property `check_have_i_been_pwned` (boolean) + +- Added property `check_zxcvbn` (boolean) + +- Added property `hibp_allowed_count` (integer) + + > How many times the password hash is allowed to be on haveibeenpwned + +- Added property `zxcvbn_score_threshold` (integer) + > If the zxcvbn score is equal or less than this value, the policy will fail. + +###### Return Type: + +Changed response : **200 OK** + +- Changed content type : `application/json` + + - Added property `check_static_rules` (boolean) + + - Added property `check_have_i_been_pwned` (boolean) + + - Added property `check_zxcvbn` (boolean) + + - Added property `hibp_allowed_count` (integer) + + > How many times the password hash is allowed to be on haveibeenpwned + + - Added property `zxcvbn_score_threshold` (integer) + > If the zxcvbn score is equal or less than this value, the policy will fail. + +##### `GET` /core/tokens/{identifier}/ + +###### Return Type: + +Changed response : **200 OK** + +- Changed content type : `application/json` + + - Changed property `user_obj` (object) + + > User Serializer + + - Changed property `groups_obj` (array) + + Changed items (object): > Simplified Group Serializer for user's groups + + New optional properties: + + - `users_obj` + + * Deleted property `users` (array) + + * Deleted property `users_obj` (array) + +##### `PUT` /core/tokens/{identifier}/ + +###### Return Type: + +Changed response : **200 OK** + +- Changed content type : `application/json` + + - Changed property `user_obj` (object) + + > User Serializer + + - Changed property `groups_obj` (array) + + Changed items (object): > Simplified Group Serializer for user's groups + + New optional properties: + + - `users_obj` + + * Deleted property `users` (array) + + * Deleted property `users_obj` (array) + +##### `PATCH` /core/tokens/{identifier}/ + +###### Return Type: + +Changed response : **200 OK** + +- Changed content type : `application/json` + + - Changed property `user_obj` (object) + + > User Serializer + + - Changed property `groups_obj` (array) + + Changed items (object): > Simplified Group Serializer for user's groups + + New optional properties: + + - `users_obj` + + * Deleted property `users` (array) + + * Deleted property `users_obj` (array) + +##### `GET` /core/users/{id}/ + +###### Return Type: + +Changed response : **200 OK** + +- Changed content type : `application/json` + + - Changed property `groups_obj` (array) + + Changed items (object): > Simplified Group Serializer for user's groups + + New optional properties: + + - `users_obj` + + * Deleted property `users` (array) + + * Deleted property `users_obj` (array) + +##### `PUT` /core/users/{id}/ + +###### Return Type: + +Changed response : **200 OK** + +- Changed content type : `application/json` + + - Changed property `groups_obj` (array) + + Changed items (object): > Simplified Group Serializer for user's groups + + New optional properties: + + - `users_obj` + + * Deleted property `users` (array) + + * Deleted property `users_obj` (array) + +##### `PATCH` /core/users/{id}/ + +###### Return Type: + +Changed response : **200 OK** + +- Changed content type : `application/json` + + - Changed property `groups_obj` (array) + + Changed items (object): > Simplified Group Serializer for user's groups + + New optional properties: + + - `users_obj` + + * Deleted property `users` (array) + + * Deleted property `users_obj` (array) + +##### `GET` /policies/bindings/{policy_binding_uuid}/ + +###### Return Type: + +Changed response : **200 OK** + +- Changed content type : `application/json` + + - Changed property `user_obj` (object) + + > User Serializer + + - Changed property `groups_obj` (array) + + Changed items (object): > Simplified Group Serializer for user's groups + + New optional properties: + + - `users_obj` + + * Deleted property `users` (array) + + * Deleted property `users_obj` (array) + +##### `PUT` /policies/bindings/{policy_binding_uuid}/ + +###### Return Type: + +Changed response : **200 OK** + +- Changed content type : `application/json` + + - Changed property `user_obj` (object) + + > User Serializer + + - Changed property `groups_obj` (array) + + Changed items (object): > Simplified Group Serializer for user's groups + + New optional properties: + + - `users_obj` + + * Deleted property `users` (array) + + * Deleted property `users_obj` (array) + +##### `PATCH` /policies/bindings/{policy_binding_uuid}/ + +###### Return Type: + +Changed response : **200 OK** + +- Changed content type : `application/json` + + - Changed property `user_obj` (object) + + > User Serializer + + - Changed property `groups_obj` (array) + + Changed items (object): > Simplified Group Serializer for user's groups + + New optional properties: + + - `users_obj` + + * Deleted property `users` (array) + + * Deleted property `users_obj` (array) + +##### `POST` /policies/password/ + +###### Request: + +Changed content type : `application/json` + +- Added property `check_static_rules` (boolean) + +- Added property `check_have_i_been_pwned` (boolean) + +- Added property `check_zxcvbn` (boolean) + +- Added property `hibp_allowed_count` (integer) + + > How many times the password hash is allowed to be on haveibeenpwned + +- Added property `zxcvbn_score_threshold` (integer) + > If the zxcvbn score is equal or less than this value, the policy will fail. + +###### Return Type: + +Changed response : **201 Created** + +- Changed content type : `application/json` + + - Added property `check_static_rules` (boolean) + + - Added property `check_have_i_been_pwned` (boolean) + + - Added property `check_zxcvbn` (boolean) + + - Added property `hibp_allowed_count` (integer) + + > How many times the password hash is allowed to be on haveibeenpwned + + - Added property `zxcvbn_score_threshold` (integer) + > If the zxcvbn score is equal or less than this value, the policy will fail. + +##### `GET` /policies/password/ + +###### Parameters: + +Added: `check_have_i_been_pwned` in `query` + +Added: `check_static_rules` in `query` + +Added: `check_zxcvbn` in `query` + +Added: `hibp_allowed_count` in `query` + +Added: `zxcvbn_score_threshold` in `query` + +###### Return Type: + +Changed response : **200 OK** + +- Changed content type : `application/json` + + - Changed property `results` (array) + + Changed items (object): > Password Policy Serializer + + - Added property `check_static_rules` (boolean) + + - Added property `check_have_i_been_pwned` (boolean) + + - Added property `check_zxcvbn` (boolean) + + - Added property `hibp_allowed_count` (integer) + + > How many times the password hash is allowed to be on haveibeenpwned + + - Added property `zxcvbn_score_threshold` (integer) + > If the zxcvbn score is equal or less than this value, the policy will fail. + +##### `POST` /core/tokens/ + +###### Return Type: + +Changed response : **201 Created** + +- Changed content type : `application/json` + + - Changed property `user_obj` (object) + + > User Serializer + + - Changed property `groups_obj` (array) + + Changed items (object): > Simplified Group Serializer for user's groups + + New optional properties: + + - `users_obj` + + * Deleted property `users` (array) + + * Deleted property `users_obj` (array) + +##### `GET` /core/tokens/ + +###### Return Type: + +Changed response : **200 OK** + +- Changed content type : `application/json` + + - Changed property `results` (array) + + Changed items (object): > Token Serializer + + - Changed property `user_obj` (object) + + > User Serializer + + - Changed property `groups_obj` (array) + + Changed items (object): > Simplified Group Serializer for user's groups + + New optional properties: + + - `users_obj` + + * Deleted property `users` (array) + + * Deleted property `users_obj` (array) + +##### `GET` /core/user_consent/{id}/ + +###### Return Type: + +Changed response : **200 OK** + +- Changed content type : `application/json` + + - Changed property `user` (object) + + > User Serializer + + - Changed property `groups_obj` (array) + + Changed items (object): > Simplified Group Serializer for user's groups + + New optional properties: + + - `users_obj` + + * Deleted property `users` (array) + + * Deleted property `users_obj` (array) + +##### `POST` /core/users/ + +###### Return Type: + +Changed response : **201 Created** + +- Changed content type : `application/json` + + - Changed property `groups_obj` (array) + + Changed items (object): > Simplified Group Serializer for user's groups + + New optional properties: + + - `users_obj` + + * Deleted property `users` (array) + + * Deleted property `users_obj` (array) + +##### `GET` /core/users/ + +###### Return Type: + +Changed response : **200 OK** + +- Changed content type : `application/json` + + - Changed property `results` (array) + + Changed items (object): > User Serializer + + - Changed property `groups_obj` (array) + + Changed items (object): > Simplified Group Serializer for user's groups + + New optional properties: + + - `users_obj` + + * Deleted property `users` (array) + + * Deleted property `users_obj` (array) + +##### `GET` /oauth2/authorization_codes/{id}/ + +###### Return Type: + +Changed response : **200 OK** + +- Changed content type : `application/json` + + - Changed property `user` (object) + + > User Serializer + + - Changed property `groups_obj` (array) + + Changed items (object): > Simplified Group Serializer for user's groups + + New optional properties: + + - `users_obj` + + * Deleted property `users` (array) + + * Deleted property `users_obj` (array) + +##### `GET` /oauth2/refresh_tokens/{id}/ + +###### Return Type: + +Changed response : **200 OK** + +- Changed content type : `application/json` + + - Changed property `user` (object) + + > User Serializer + + - Changed property `groups_obj` (array) + + Changed items (object): > Simplified Group Serializer for user's groups + + New optional properties: + + - `users_obj` + + * Deleted property `users` (array) + + * Deleted property `users_obj` (array) + +##### `POST` /policies/bindings/ + +###### Return Type: + +Changed response : **201 Created** + +- Changed content type : `application/json` + + - Changed property `user_obj` (object) + + > User Serializer + + - Changed property `groups_obj` (array) + + Changed items (object): > Simplified Group Serializer for user's groups + + New optional properties: + + - `users_obj` + + * Deleted property `users` (array) + + * Deleted property `users_obj` (array) + +##### `GET` /policies/bindings/ + +###### Return Type: + +Changed response : **200 OK** + +- Changed content type : `application/json` + + - Changed property `results` (array) + + Changed items (object): > PolicyBinding Serializer + + - Changed property `user_obj` (object) + + > User Serializer + + - Changed property `groups_obj` (array) + + Changed items (object): > Simplified Group Serializer for user's groups + + New optional properties: + + - `users_obj` + + * Deleted property `users` (array) + + * Deleted property `users_obj` (array) + +##### `GET` /core/user_consent/ + +###### Return Type: + +Changed response : **200 OK** + +- Changed content type : `application/json` + + - Changed property `results` (array) + + Changed items (object): > UserConsent Serializer + + - Changed property `user` (object) + + > User Serializer + + - Changed property `groups_obj` (array) + + Changed items (object): > Simplified Group Serializer for user's groups + + New optional properties: + + - `users_obj` + + * Deleted property `users` (array) + + * Deleted property `users_obj` (array) + +##### `GET` /oauth2/authorization_codes/ + +###### Return Type: + +Changed response : **200 OK** + +- Changed content type : `application/json` + + - Changed property `results` (array) + + Changed items (object): > Serializer for BaseGrantModel and ExpiringBaseGrant + + - Changed property `user` (object) + + > User Serializer + + - Changed property `groups_obj` (array) + + Changed items (object): > Simplified Group Serializer for user's groups + + New optional properties: + + - `users_obj` + + * Deleted property `users` (array) + + * Deleted property `users_obj` (array) + +##### `GET` /oauth2/refresh_tokens/ + +###### Return Type: + +Changed response : **200 OK** + +- Changed content type : `application/json` + + - Changed property `results` (array) + + Changed items (object): > Serializer for BaseGrantModel and RefreshToken + + - Changed property `user` (object) + + > User Serializer + + - Changed property `groups_obj` (array) + + Changed items (object): > Simplified Group Serializer for user's groups + + New optional properties: + + - `users_obj` + + * Deleted property `users` (array) + + * Deleted property `users_obj` (array) ## Minor changes/fixes