Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

policies: add unique password policy #10631

Open
wants to merge 25 commits into
base: main
Choose a base branch
from

Conversation

verkaufer
Copy link

@verkaufer verkaufer commented Jul 25, 2024

Details

Closes #8307

Adds a "Password Uniqueness" policy to the set of configurable flow policies.

Documentation PR: #11000

How it works

After at least one (1) Flow has a UniquePasswordPolicy binding attached & enabled, the system records every user's hashed password when the user changes their own password.

When the user submits a new password during a password-change Flow, the system will check if the new password is identical to any of the user's previous passwords. The check will hash the new password against the Hasher originally used to hash the old password. This ensures backwards compatiblity if Authentik decides to change hashing algorithsm.

This check only occurs if the Flow Stage has a Unique Password Policy attached.

Configuration

Admins can configure how many previous passwords the system should evaluate.

The configured number of passwords to evaluate also defines the number of passwords retained for each user.

Admins may also define which field they wish to use as the "password" field in a Stage. This is similar to the Password Policy configuration.

Data considerations

The system purges the password history table after the last enabled UniquePasswordPolicy binding is deleted.

Because the number of passwords we save is configurable, it's important admins understand that whatever value they configure will end up saving up to n^m passwords, were n is the configured policy value & m is the number of users.


Checklist

  • Local tests pass (ak test authentik/)
  • The code has been formatted (make lint-fix)

If an API change has been made

  • The API schema has been updated (make gen-build)

If changes to the frontend have been made

  • The code has been formatted (make web)

If applicable

  • The documentation has been updated
  • The documentation has been formatted (make website)

@verkaufer verkaufer requested review from a team as code owners July 25, 2024 04:53
Copy link

netlify bot commented Jul 25, 2024

Deploy Preview for authentik-storybook ready!

Name Link
🔨 Latest commit 4ff7b9b
🔍 Latest deploy log https://app.netlify.com/sites/authentik-storybook/deploys/66c0f50d6677050008d68b7f
😎 Deploy Preview https://deploy-preview-10631--authentik-storybook.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

Copy link

netlify bot commented Jul 25, 2024

Deploy Preview for authentik-docs ready!

Name Link
🔨 Latest commit 4ff7b9b
🔍 Latest deploy log https://app.netlify.com/sites/authentik-docs/deploys/66c0f50d42f5d2000835eea1
😎 Deploy Preview https://deploy-preview-10631--authentik-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

@verkaufer verkaufer force-pushed the dg/8307-unique-passwords branch 3 times, most recently from 90a6866 to 8a89b13 Compare July 31, 2024 04:23
@verkaufer verkaufer changed the title Draft: Implement a 'unique password required' policy Implement a 'unique password required' policy Jul 31, 2024
authentik/core/tasks.py Outdated Show resolved Hide resolved
authentik/events/signals.py Outdated Show resolved Hide resolved
for history in password_history:
old_password = history.change.get("old_password")
if not old_password:
# TODO: how do we handle missing password?
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feedback requested: how should the policy behave if the UserPasswordHistory record doesn't have an old_password key?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we indeed go with a field on the object, we avoid having to ask ourselves those questions.

authentik/core/models.py Outdated Show resolved Hide resolved
LOGGER.warning(
"Could not load hash algorithm for old password.",
)
# TODO: Define behavior if hasher cannot be identified or is unsupported
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the policy fail if the Password hash algorithm was removed from Django? I know at least 2 are scheduled for deprecation in 5.1.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should just ignore unknown algos, and maybe schedule a task to remove ones we don't know about?

Copy link
Author

@verkaufer verkaufer Aug 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ignoring them works for me. Django won't be able to compare against that password, and in this implementation it might as well not exist.

  • Ignore passwords where we can't identify the algo

maybe schedule a task to remove ones we don't know about

I'm not sure the overhead of running a task (and spending the time to write it) would get us much in return. Passwords are already cleared out in a LIFO-esque way, so eventually the unknown password would age out.

@verkaufer verkaufer changed the title Implement a 'unique password required' policy core/policy: Implement a 'unique password required' policy Jul 31, 2024
Copy link

codecov bot commented Aug 1, 2024

Codecov Report

Attention: Patch coverage is 29.09091% with 156 lines in your changes missing coverage. Please review.

Project coverage is 50.84%. Comparing base (3ab9750) to head (8a89b13).
Report is 28 commits behind head on main.

Files Patch % Lines
authentik/policies/password/tests/test_policy.py 0.00% 42 Missing ⚠️
authentik/core/tests/test_tasks.py 0.00% 34 Missing ⚠️
authentik/stages/user_write/tests.py 0.00% 27 Missing ⚠️
authentik/policies/password/models.py 34.21% 25 Missing ⚠️
authentik/core/tasks.py 25.92% 20 Missing ⚠️
authentik/policies/signals.py 58.33% 5 Missing ⚠️
authentik/events/signals.py 78.57% 3 Missing ⚠️

❗ There is a different number of reports uploaded between BASE (3ab9750) and HEAD (8a89b13). Click for more details.

HEAD has 2 uploads less than BASE
Flag BASE (3ab9750) HEAD (8a89b13)
unit 2 0
Additional details and impacted files
@@             Coverage Diff             @@
##             main   #10631       +/-   ##
===========================================
- Coverage   92.59%   50.84%   -41.75%     
===========================================
  Files         720      718        -2     
  Lines       35254    35196       -58     
===========================================
- Hits        32642    17896    -14746     
- Misses       2612    17300    +14688     
Flag Coverage Δ
e2e 49.49% <29.09%> (-0.25%) ⬇️
integration 25.30% <19.09%> (-0.05%) ⬇️
unit ?

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Copy link
Member

@rissson rissson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some design comments, most of which will need to be checked by @BeryJu anyway

No specific review on the frontend, cause I wouldn't know what I'm saying.

I think we should also provide a way for administrators to clear the password history of a specific user (and all users?) so that a user can be unblocked from changing their password if needed.

All in all, a pretty solid implementation

authentik/events/signals.py Outdated Show resolved Hide resolved
authentik/core/models.py Outdated Show resolved Hide resolved
authentik/core/tasks.py Outdated Show resolved Hide resolved
authentik/policies/password/models.py Outdated Show resolved Hide resolved
authentik/core/models.py Outdated Show resolved Hide resolved
authentik/policies/password/models.py Outdated Show resolved Hide resolved
for history in password_history:
old_password = history.change.get("old_password")
if not old_password:
# TODO: how do we handle missing password?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we indeed go with a field on the object, we avoid having to ask ourselves those questions.

fields=request.context.keys(),
)
return PolicyResult(False, _("Password not set in context"))
password = str(password)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should already be the case?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be 🤷 The other Password policy applies the same str casting and I figured that was done on purpose.

I can remove if @BeryJu feels the same way

authentik/policies/signals.py Outdated Show resolved Hide resolved
@BeryJu BeryJu changed the title core/policy: Implement a 'unique password required' policy policy: Implement a 'unique password required' policy Aug 8, 2024
@rissson rissson changed the title policy: Implement a 'unique password required' policy policies: add unique password policy Aug 8, 2024
@verkaufer
Copy link
Author

verkaufer commented Aug 9, 2024

@rissson I've applied the suggested changes. Namely:

  • move UniquePasswordPolicy to its own policies sub-app
  • move UserPasswordHistory out of core and into policies.unique_password
  • add a Model Manager for finding all instances of in-use PolicyBindings for a Policy type[1]: PolicyBindings.in_use.for_policy(<Policy>)

❗ One issue I'm stumped on:
When I moved the "write password on user_write signal" code, I'm no longer able to get my test_stages.pytests to pass. It seems like user_write is not received inside the policies.unique_password package.

When I had the code alongside the events listener for user_write it worked

Resolved after fixing #10631 (comment)


[1] Name of the model manager method is definitely up for discussion

@verkaufer
Copy link
Author

Also I'll rebase and get the schema conflict resolved before merging

"""QuerySet for filtering enabled bindings for a Policy type"""

def for_policy(self, policy: "Policy"):
return self.filter(policy__in=policy._default_manager.all()).filter(enabled=True)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to make sure I understand, why are we referring to the default manager here?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I chose _default_manager because I wanted to preserve the behavior of the InheritanceManager() managers on the different Policy subclasses. Is that unnecessary here?

…ry table

If the UniquePasswordPolicy is enabled anywhere, we now record the user's hashed password.
The system should aim to keep the number of historical passwords to a minimum to avoid wasting storage space.

Admins can configure how many passwords they want to preserve. If multiple instances of the UniquePasswordPolicy exist, the system takes the max() value of all enabled policies to determine how many passwords should remain after trimming.
@verkaufer verkaufer requested a review from rissson August 17, 2024 19:09
verkaufer added a commit to verkaufer/authentik that referenced this pull request Aug 21, 2024
@verkaufer
Copy link
Author

Added public documentation in a separate PR: #11000

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Checking the new user password to make sure it is different from the previous ones
2 participants