Skip to content

[fix] Don't block SSO and REST login when password is expired #511 - #544

Open
pandafy wants to merge 10 commits into
masterfrom
issues/511-passowrd-expiration-sso
Open

[fix] Don't block SSO and REST login when password is expired #511#544
pandafy wants to merge 10 commits into
masterfrom
issues/511-passowrd-expiration-sso

Conversation

@pandafy

@pandafy pandafy commented Jul 28, 2026

Copy link
Copy Markdown
Member

Checklist

Reference to Existing Issue

Fixes #511

Description of Changes

Added REST API endpoints to allow users to reset and change their password.

@pandafy pandafy self-assigned this Jul 28, 2026
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The change tracks authentication methods, enforces password expiration for local-password sessions, and adds REST password reset, confirmation, and authenticated password-change endpoints. It adds custom reset forms and email templates, structured REST expiration responses, conditional routes, documentation, dependency updates, and coverage for authentication, middleware, recovery, and sample-app behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant PasswordExpirationMiddleware
  participant AuthenticationSession
  participant RESTAPIView
  Client->>PasswordExpirationMiddleware: Send request
  PasswordExpirationMiddleware->>AuthenticationSession: Check authentication method and expiry
  AuthenticationSession-->>PasswordExpirationMiddleware: Return expired-password state
  PasswordExpirationMiddleware->>RESTAPIView: Render password_expired response
  RESTAPIView-->>Client: Return HTTP 403 with recovery URL
Loading

Possibly related PRs

Suggested labels: enhancement

Suggested reviewers: nemesifier


Caution

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

  • Ignore

❌ Failed checks (3 errors, 1 warning)

Check name Status Explanation Resolution
Linked Issues check ❌ Error The PR covers authentication tracking, expiration handling, REST recovery, and reset APIs, but provides no evidence of required RADIUS compatibility and organization-validation fixes. Add or link the RADIUS compatibility wrappers and regression coverage for organization validation in both form-encoded and JSON reset confirmations.
Bug Fixes ❌ Error Expired-password token issuance remains allowed: ObtainAuthTokenView returns 200/token and tests assert success, contradicting #511's required 403 rejection; no Selenium regression test covers the... Restore expired-password rejection in ObtainAuthTokenView and test 403/password_expired/no token. Add a Selenium test for the browser password-expiration flow if UI coverage is required.
General Rules ❌ Error The HTML reset template omits call_to_action_url, so the added link test fails; the exempted token route also issues tokens for expired passwords, contrary to issue #511. Render a translated HTML anchor with call_to_action_url; reject expired-password credentials at token issuance while keeping reset and password-change recovery routes available.
Changes ⚠️ Warning Documentation and tests are present, but token issuance is exempted and tests expect 200 for expired passwords, contrary to the linked issue's required rejection before token creation. Reject expired-password token requests with the password_expired 403 response before Token.objects.get_or_create, remove the token-route exemption, and add a no-token regression test.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title uses the required [fix] prefix and clearly describes the password-expiration changes for SSO and REST authentication.
Description check ✅ Passed The description completes the checklist, references issue #511, and summarizes the new password reset and change endpoints.
Out of Scope Changes check ✅ Passed The model, authentication, middleware, API, documentation, dependency, and test changes support the linked issue objectives.
Features ✅ Passed Issue #511 exists with a bug label and maintainer-only marker; REST docs cover all endpoints, focused API tests were added, and no UI assets changed.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issues/511-passowrd-expiration-sso

Comment @coderabbitai help to get the list of available commands.

@openwisp-companion

Copy link
Copy Markdown

Test Failure: Assertion Error

Hello @pandafy,
(Analysis for commit 929011c)

The CI failed because the test test_external_session_bypasses_expired_password_check in openwisp_users/tests/test_accounts.py expected a status code of 200 but received 302.

Fix:

The test assertion self.assertEqual(response.status_code, 200) should be changed to self.assertEqual(response.status_code, 302) to match the actual response status code. This indicates that the redirect is the expected behavior in this scenario.

Added REST API endpoints to allow users to reset and change their
password.

Fixes #511
@pandafy
pandafy force-pushed the issues/511-passowrd-expiration-sso branch from 929011c to 393fee6 Compare July 28, 2026 17:53

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@openwisp_users/api/views.py`:
- Around line 265-278: Add the `rest_password_change` route name to the
password-expiration exemptions used by `PasswordExpirationMiddleware`, alongside
the existing `change_password` exemption. Keep `PasswordChangeView` and its
authentication behavior unchanged so expired-password clients can reach the
self-service recovery endpoint.

In `@openwisp_users/base/forms.py`:
- Around line 16-24: Update get_users to use a queryset lookup that returns an
empty result for an unknown email instead of calling User.objects.get. Preserve
the existing has_usable_password filtering and return shape for matching users.
- Around line 85-109: Update the send_mail method to ensure the from_email
parameter is honored when save() supplies a custom sender, forwarding it through
the existing send_email/context mechanism as supported by the API. If this
override cannot use from_email, remove the unused parameter and adjust callers
consistently.

In `@openwisp_users/middleware.py`:
- Around line 70-74: Update _blocked_response to pass request.path_info to
resolve instead of request.path, ensuring URL resolution works correctly when
the application is mounted under a script prefix and expired-password sessions
remain blocked.

In `@openwisp_users/templates/custom_password_reset_email.html`:
- Around line 8-10: Update the HTML password-reset email template near the
instructional text to render the `call_to_action_url` as a usable password-reset
link, preserving the existing translated message and ensuring HTML-preferred
clients can follow the reset action.

In `@openwisp_users/tests/test_api/test_password_reset.py`:
- Around line 49-58: Update
test_reset_request_with_unknown_identifier_is_indistinguishable to submit a
reset request for a known identifier and compare the unknown-user response’s
status_code and data against that known-user response, preserving the existing
no-email assertion and anti-enumeration behavior.

In `@tests/openwisp2/sample_users/tests.py`:
- Around line 150-157: Add a TestPasswordChangeAPI subclass alongside
TestPasswordResetAPI in the sample app test module, inheriting from the existing
base test class defined in test_password_reset.py, so the custom User model is
covered by the password-change endpoint tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9fdbfcab-3096-48e8-9950-62986726dd4a

📥 Commits

Reviewing files that changed from the base of the PR and between 3b6653d and 929011c.

📒 Files selected for processing (19)
  • docs/user/rest-api.rst
  • openwisp_users/api/serializers.py
  • openwisp_users/api/urls.py
  • openwisp_users/api/views.py
  • openwisp_users/apps.py
  • openwisp_users/auth.py
  • openwisp_users/base/forms.py
  • openwisp_users/middleware.py
  • openwisp_users/templates/custom_password_reset_email.html
  • openwisp_users/templates/custom_password_reset_email.txt
  • openwisp_users/tests/test_accounts.py
  • openwisp_users/tests/test_admin.py
  • openwisp_users/tests/test_api/test_password_reset.py
  • openwisp_users/tests/test_api/test_views.py
  • openwisp_users/tests/test_auth.py
  • openwisp_users/tests/test_middlewares.py
  • requirements.txt
  • tests/openwisp2/sample_users/tests.py
  • tests/openwisp2/settings.py
📜 Review details
⏰ Context from checks skipped due to timeout. (14)
  • GitHub Check: Python==3.10 | django~=4.2.0
  • GitHub Check: Python==3.12 | django~=4.2.0
  • GitHub Check: Python==3.11 | django~=5.0.0
  • GitHub Check: Python==3.12 | django~=5.0.0
  • GitHub Check: Python==3.10 | django~=5.2.0
  • GitHub Check: Python==3.11 | django~=4.2.0
  • GitHub Check: Python==3.12 | django~=5.2.0
  • GitHub Check: Python==3.13 | django~=5.2.0
  • GitHub Check: Python==3.10 | django~=5.1.0
  • GitHub Check: Python==3.13 | django~=5.1.0
  • GitHub Check: Python==3.11 | django~=5.2.0
  • GitHub Check: Python==3.11 | django~=5.1.0
  • GitHub Check: Python==3.12 | django~=5.1.0
  • GitHub Check: Kilo Code Review
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

**/*.py: Mark user-facing strings for translation with Django i18n helpers in Django code
Place imports at the top of the file; only defer imports when necessary (e.g., Django model imports inside functions or methods where the app registry is not yet ready)
Avoid unnecessary blank lines inside function and method bodies
Write comments and docstrings only when they explain why code is shaped a certain way; place comments before the relevant code block instead of scattering them inside it

For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework.

Files:

  • tests/openwisp2/settings.py
  • openwisp_users/api/urls.py
  • openwisp_users/auth.py
  • openwisp_users/tests/test_admin.py
  • openwisp_users/base/forms.py
  • openwisp_users/tests/test_auth.py
  • openwisp_users/tests/test_api/test_views.py
  • openwisp_users/api/serializers.py
  • openwisp_users/tests/test_accounts.py
  • openwisp_users/tests/test_api/test_password_reset.py
  • tests/openwisp2/sample_users/tests.py
  • openwisp_users/tests/test_middlewares.py
  • openwisp_users/api/views.py
  • openwisp_users/apps.py
  • openwisp_users/middleware.py
🪛 ast-grep (0.45.0)
openwisp_users/api/serializers.py

[warning] 21-21: Loading a Keras model from an untrusted file can execute arbitrary code via Lambda layers or custom objects. Load only trusted models and avoid deserializing custom objects from untrusted sources.
Context: load_model("openwisp_users", "Group")
Note: [CWE-502] Deserialization of Untrusted Data.

(keras-load-model-python)

🪛 HTMLHint (1.9.2)
openwisp_users/templates/custom_password_reset_email.html

[error] 1-1: Doctype must be declared before any non-comment content.

(doctype-first)

🔇 Additional comments (17)
openwisp_users/auth.py (1)

1-53: LGTM!

openwisp_users/apps.py (1)

3-18: LGTM!

Also applies to: 143-170

openwisp_users/tests/test_auth.py (1)

1-34: LGTM!

openwisp_users/middleware.py (1)

3-69: LGTM!

Also applies to: 85-105

openwisp_users/tests/test_admin.py (1)

10-10: LGTM!

Also applies to: 28-28, 2460-2465

openwisp_users/templates/custom_password_reset_email.txt (1)

1-8: LGTM!

docs/user/rest-api.rst (1)

71-140: LGTM!

requirements.txt (1)

4-4: LGTM!

tests/openwisp2/settings.py (1)

39-39: LGTM!

openwisp_users/tests/test_accounts.py (1)

4-16: LGTM!

Also applies to: 31-73

openwisp_users/api/views.py (1)

2-9: LGTM!

Also applies to: 24-25, 36-36, 69-132, 341-350

openwisp_users/tests/test_api/test_views.py (1)

1-10: LGTM!

Also applies to: 25-48, 71-91

openwisp_users/tests/test_middlewares.py (1)

3-10: LGTM!

Also applies to: 48-54, 55-58, 61-67, 69-73, 75-100, 102-113

openwisp_users/api/serializers.py (1)

5-11: LGTM!

Also applies to: 20-21, 481-529

openwisp_users/api/urls.py (1)

49-64: LGTM!

openwisp_users/tests/test_api/test_password_reset.py (1)

1-48: LGTM!

Also applies to: 60-147

tests/openwisp2/sample_users/tests.py (1)

19-31: LGTM!

Also applies to: 169-170

Comment thread openwisp_users/api/views.py Outdated
Comment thread openwisp_users/base/forms.py Outdated
Comment thread openwisp_users/base/forms.py
Comment thread openwisp_users/middleware.py
Comment thread openwisp_users/templates/custom_password_reset_email.html
Comment thread openwisp_users/tests/test_api/test_password_reset.py Outdated
Comment thread tests/openwisp2/sample_users/tests.py
@openwisp-companion

Copy link
Copy Markdown

Test Failures in Password Reset API

Hello @pandafy,
(Analysis for commit 393fee6)

The CI failed due to several test failures in the password reset API tests.

  1. IndexError: pop from empty list: This error occurs in _get_reset_url_from_outbox within tests/test_api/test_password_reset.py. It indicates that the mail.outbox is empty when pop() is called, meaning no emails were sent or captured. This suggests that the password reset email generation or sending mechanism might not be working as expected in the test environment.
  • Fix: Ensure that the password reset process correctly triggers email sending and that the test setup correctly captures these emails. You might need to review the test setup for email capturing or the logic that generates the reset URL.
  1. AssertionError: 429 != 200: This error appears in multiple tests (test_reset_request_email_has_plain_and_html_parts_with_matching_link, test_reset_request_with_unknown_identifier_is_indistinguishable, test_reset_request_with_valid_email_sends_email, test_reset_request_with_valid_phone_number_sends_email, test_reset_request_with_valid_username_sends_email). It signifies that the API returned a 429 Too Many Requests status code when 200 OK was expected. This often points to rate limiting being unexpectedly triggered during tests.
  • Fix: Investigate why the rate limiting is being triggered. It's possible that the test requests are being made too rapidly, or the rate limiting configuration is too strict for the test environment. You might need to adjust the rate limiting settings for tests or space out the requests within the tests.

Please address these issues and push a new commit.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
openwisp_users/templates/custom_password_reset_email.html (1)

8-10: 🎯 Functional Correctness | 🟠 Major

Render the reset URL in the HTML email.

The template still ends after the instructions and never outputs call_to_action_url, so HTML-preferred clients receive no usable reset link. This is the same unresolved issue from the previous review.

 {% trans "Please click on the button below to open a page where you can choose a new password:" %}
+<p><a href="{{ call_to_action_url }}" class="btn">{% trans "Reset your password" %}</a></p>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/templates/custom_password_reset_email.html` around lines 8 -
10, Update the password reset HTML template after the instructional paragraph to
render the call_to_action_url as the reset link, ensuring HTML email clients
receive a usable URL while preserving the existing translated text.
openwisp_users/middleware.py (1)

72-75: 🔒 Security & Privacy | 🟠 Major

Resolve against request.path_info, not request.path.

Django documents that request.path may include the deployment script prefix while request.path_info excludes it. Under a prefix such as /app/, resolve(request.path) can raise Resolver404; this method then returns None, allowing expired-password sessions to reach protected views. (docs.djangoproject.com)

-            resolver_match = resolve(request.path)
+            resolver_match = resolve(request.path_info)

This is the same unresolved finding from the previous review.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/middleware.py` around lines 72 - 75, Update _blocked_response
to pass request.path_info to resolve instead of request.path, ensuring URL
resolution works when the application is deployed under a script prefix and
preserving the existing Resolver404 handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@openwisp_users/apps.py`:
- Around line 168-170: Update handle_allauth_login to also call
set_authentication_method(request, EXTERNAL) for passwordless login-by-code
sessions, while preserving the existing sociallogin behavior. Add a regression
test verifying code-based logins are marked EXTERNAL and expired users are not
blocked.

---

Duplicate comments:
In `@openwisp_users/middleware.py`:
- Around line 72-75: Update _blocked_response to pass request.path_info to
resolve instead of request.path, ensuring URL resolution works when the
application is deployed under a script prefix and preserving the existing
Resolver404 handling.

In `@openwisp_users/templates/custom_password_reset_email.html`:
- Around line 8-10: Update the password reset HTML template after the
instructional paragraph to render the call_to_action_url as the reset link,
ensuring HTML email clients receive a usable URL while preserving the existing
translated text.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 71fe4911-2b24-4066-a1a3-1ad9743a77d1

📥 Commits

Reviewing files that changed from the base of the PR and between 929011c and 393fee6.

📒 Files selected for processing (19)
  • docs/user/rest-api.rst
  • openwisp_users/api/serializers.py
  • openwisp_users/api/urls.py
  • openwisp_users/api/views.py
  • openwisp_users/apps.py
  • openwisp_users/auth.py
  • openwisp_users/base/forms.py
  • openwisp_users/middleware.py
  • openwisp_users/templates/custom_password_reset_email.html
  • openwisp_users/templates/custom_password_reset_email.txt
  • openwisp_users/tests/test_accounts.py
  • openwisp_users/tests/test_admin.py
  • openwisp_users/tests/test_api/test_password_reset.py
  • openwisp_users/tests/test_api/test_views.py
  • openwisp_users/tests/test_auth.py
  • openwisp_users/tests/test_middlewares.py
  • requirements.txt
  • tests/openwisp2/sample_users/tests.py
  • tests/openwisp2/settings.py
📜 Review details
⏰ Context from checks skipped due to timeout. (10)
  • GitHub Check: Python==3.13 | django~=5.1.0
  • GitHub Check: Python==3.13 | django~=5.2.0
  • GitHub Check: Python==3.11 | django~=5.1.0
  • GitHub Check: Python==3.12 | django~=5.1.0
  • GitHub Check: Python==3.10 | django~=5.0.0
  • GitHub Check: Python==3.12 | django~=5.2.0
  • GitHub Check: Python==3.10 | django~=5.1.0
  • GitHub Check: Python==3.10 | django~=5.2.0
  • GitHub Check: Python==3.11 | django~=5.2.0
  • GitHub Check: Python==3.12 | django~=5.0.0
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

**/*.py: Mark user-facing strings for translation with Django i18n helpers in Django code
Place imports at the top of the file; only defer imports when necessary (e.g., Django model imports inside functions or methods where the app registry is not yet ready)
Avoid unnecessary blank lines inside function and method bodies
Write comments and docstrings only when they explain why code is shaped a certain way; place comments before the relevant code block instead of scattering them inside it

For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework.

Files:

  • openwisp_users/api/urls.py
  • openwisp_users/tests/test_auth.py
  • tests/openwisp2/settings.py
  • openwisp_users/tests/test_admin.py
  • tests/openwisp2/sample_users/tests.py
  • openwisp_users/auth.py
  • openwisp_users/api/serializers.py
  • openwisp_users/tests/test_middlewares.py
  • openwisp_users/base/forms.py
  • openwisp_users/tests/test_api/test_views.py
  • openwisp_users/tests/test_api/test_password_reset.py
  • openwisp_users/apps.py
  • openwisp_users/tests/test_accounts.py
  • openwisp_users/middleware.py
  • openwisp_users/api/views.py
🪛 ast-grep (0.45.0)
openwisp_users/api/serializers.py

[warning] 21-21: Loading a Keras model from an untrusted file can execute arbitrary code via Lambda layers or custom objects. Load only trusted models and avoid deserializing custom objects from untrusted sources.
Context: load_model("openwisp_users", "Group")
Note: [CWE-502] Deserialization of Untrusted Data.

(keras-load-model-python)

🪛 HTMLHint (1.9.2)
openwisp_users/templates/custom_password_reset_email.html

[error] 1-1: Doctype must be declared before any non-comment content.

(doctype-first)

🔇 Additional comments (21)
openwisp_users/auth.py (1)

1-14: LGTM!

Also applies to: 17-30, 33-53

openwisp_users/apps.py (1)

3-7: LGTM!

Also applies to: 18-18, 143-167

openwisp_users/tests/test_auth.py (1)

1-35: LGTM!

openwisp_users/middleware.py (1)

3-70: LGTM!

Also applies to: 87-108

openwisp_users/api/urls.py (1)

49-64: LGTM!

openwisp_users/templates/custom_password_reset_email.txt (1)

1-8: LGTM!

docs/user/rest-api.rst (1)

71-140: LGTM!

requirements.txt (1)

4-4: LGTM!

tests/openwisp2/settings.py (1)

39-39: LGTM!

openwisp_users/api/views.py (2)

264-277: Past exemption gap now resolved.

Earlier review flagged rest_password_change as missing from PasswordExpirationMiddleware.exempted_url_names. The current middleware context already includes API_PASSWORD_CHANGE_URL_NAME in that list (checked against both url_name and view_name), and test_expired_password_session_can_use_rest_password_change exercises this path successfully. No further action needed here.


68-79: LGTM!

Also applies to: 81-131, 340-349

openwisp_users/base/forms.py (2)

16-24: 🩺 Stability & Availability | ⚡ Quick win

.get() still raises DoesNotExist for an unmatched email.

Previously flagged: User.objects.get(email=email) turns a normal reset request for an unrecognized address into a 500 instead of returning no users. This is a public extension point per the docstring ("subclasses can customize"), so a caller supplying an arbitrary/unresolved email will crash here.

🐛 Proposed fix
-        user = User.objects.get(email=email)
-        return [user] if user.has_usable_password() else []
+        return [
+            user
+            for user in User.objects.filter(email=email)
+            if user.has_usable_password()
+        ]

85-109: 🗄️ Data Integrity & Integration | ⚡ Quick win

from_email is still silently dropped.

Previously flagged: send_mail accepts from_email as a parameter but never passes it to send_email(...). PasswordResetSerializer.save() explicitly sets opts["from_email"] = getattr(settings, "DEFAULT_FROM_EMAIL"), but that value is discarded here, so reset emails always use openwisp_utils's default sender regardless of DEFAULT_FROM_EMAIL.

openwisp_utils admin_theme email send_email function signature from_email argument
openwisp_users/tests/test_api/test_password_reset.py (2)

49-58: 🎯 Functional Correctness | ⚡ Quick win

Tautological self-comparison doesn't verify indistinguishability.

Still comparing unknown_user_response to itself, so this can never fail even if the unknown-identifier response differed from a known-identifier one. Compare against a response for a known identifier instead, per the previous suggestion.

🧪 Proposed fix
     def test_reset_request_with_unknown_identifier_is_indistinguishable(self):
+        known_user_response = self.client.post(
+            self.request_url, {"input": "tester"}
+        )
+        mail.outbox.clear()
         unknown_user_response = self.client.post(
             self.request_url, {"input": "does-not-exist"}
         )
         self.assertEqual(unknown_user_response.status_code, 200)
         self.assertEqual(len(mail.outbox), 0)
         self.assertEqual(
-            unknown_user_response.status_code, unknown_user_response.status_code
+            unknown_user_response.status_code, known_user_response.status_code
         )
-        self.assertEqual(unknown_user_response.data, unknown_user_response.data)
+        self.assertEqual(unknown_user_response.data, known_user_response.data)

1-47: LGTM!

Also applies to: 60-147

tests/openwisp2/sample_users/tests.py (1)

19-31: 📐 Maintainability & Code Quality | ⚡ Quick win

TestPasswordChangeAPI still isn't wired into the sample app's test suite.

TestPasswordResetAPI is imported/subclassed here, but TestPasswordChangeAPI from the same module is not, so the sample app's custom User model isn't exercised against the new password-change endpoint.

✅ Proposed fix
 from openwisp_users.tests.test_api.test_password_reset import (
     TestPasswordResetAPI as BaseTestPasswordResetAPI,
+    TestPasswordChangeAPI as BaseTestPasswordChangeAPI,
 )
 class TestPasswordResetAPI(BaseTestPasswordResetAPI):
     pass
+
+
+class TestPasswordChangeAPI(BaseTestPasswordChangeAPI):
+    pass
 del BaseTestPasswordExpirationMiddleware
 del BaseTestPasswordResetAPI
+del BaseTestPasswordChangeAPI

Also applies to: 150-156, 169-170

openwisp_users/tests/test_accounts.py (1)

4-16: LGTM!

Also applies to: 31-73

openwisp_users/tests/test_admin.py (1)

10-10: LGTM!

Also applies to: 28-28, 2460-2465

openwisp_users/tests/test_api/test_views.py (1)

1-10: LGTM!

Also applies to: 25-48, 71-91

openwisp_users/tests/test_middlewares.py (1)

3-10: LGTM!

Also applies to: 48-130

openwisp_users/api/serializers.py (1)

5-11: LGTM!

Also applies to: 20-21, 481-528

Comment thread openwisp_users/apps.py Outdated
Comment thread openwisp_users/apps.py
@classmethod
def handle_password_login(cls, request, **kwargs):
backend = getattr(kwargs.get("user"), "backend", "")
if backend == "sesame.backends.ModelBackend":

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We need explicit handling for Sesame backend so we can except users logging in with magic links.

@openwisp-companion

Copy link
Copy Markdown

CI Failures: Code Style and Imports

Hello @pandafy,
(Analysis for commit 2b2f53c)

There are multiple code style and import sorting issues detected by flake8 and isort.

  • Code Style (Flake8): Unused imports were found in openwisp_users/api/views.py and openwisp_users/tests/test_api/test_views.py. Please remove these unused imports.
  • Import Sorting (Isort): Imports in openwisp_users/tests/test_accounts.py are not correctly sorted or formatted.

To fix these issues, please run the following command in your local environment:

openwisp-qa-format

This command will automatically format your code and sort the imports according to the project's standards.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
openwisp_users/base/forms.py (1)

46-51: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Move the settings import to module scope.

django.conf.settings does not require deferred importing here; keeping it inside save() violates the repository’s import-placement guideline.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/base/forms.py` around lines 46 - 51, Move the
django.conf.settings import from the save() method to module scope, alongside
the other imports, and continue using settings.INSTALLED_APPS in the existing
conditional without changing the token-generator selection.

Source: Coding guidelines

openwisp_users/tests/test_middlewares.py (1)

121-133: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Exercise bearer authentication alongside an expired session.

This test uses an unauthenticated client, so it never verifies the middleware behavior when an expired password session and a Bearer token are both present. Log the expired admin in before sending the token request, or explicitly document that mixed credentials are unsupported.

Suggested coverage change
-        admin = self._create_admin()
+        admin = self._login_expired_admin()
         token = Token.objects.create(user=admin)
-        admin.password_updated = now().date() - timedelta(days=180)
-        admin.save()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/tests/test_middlewares.py` around lines 121 - 133, Update
test_expired_password_session_bearer_request_not_blocked_for_existing_token to
authenticate the expired admin in the client before issuing the Bearer-token
request, while preserving the existing token setup and expected 200 response.
openwisp_users/api/views.py (1)

68-69: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reinstate the password-expiration check before minting auth tokens.

  • openwisp_users/api/views.py#L68-L69: super().post() now issues a token even when the password is expired; return a 403 with password_expired before delegating.
  • openwisp_users/tests/test_api/test_views.py#L25-L36: update the regression test to expect rejection and no token.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/api/views.py` around lines 68 - 69, The post method in
openwisp_users/api/views.py (lines 68-69) must check for an expired password and
return HTTP 403 with password_expired before calling super().post(); update
openwisp_users/tests/test_api/test_views.py (lines 25-36) to assert rejection
and confirm no token is issued.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@openwisp_users/base/forms.py`:
- Around line 23-24: Update the user query in the password-usable user lookup to
use the case-insensitive email lookup `email__iexact` instead of exact `email`,
while preserving the existing filtering and return behavior.

In `@openwisp_users/tests/test_accounts.py`:
- Line 3: Update the account tests to use settings.SESSION_COOKIE_NAME instead
of the hardcoded “sessionid” wherever session cookies are referenced, preserving
the existing authentication and follow-up request behavior.

---

Outside diff comments:
In `@openwisp_users/api/views.py`:
- Around line 68-69: The post method in openwisp_users/api/views.py (lines
68-69) must check for an expired password and return HTTP 403 with
password_expired before calling super().post(); update
openwisp_users/tests/test_api/test_views.py (lines 25-36) to assert rejection
and confirm no token is issued.

In `@openwisp_users/base/forms.py`:
- Around line 46-51: Move the django.conf.settings import from the save() method
to module scope, alongside the other imports, and continue using
settings.INSTALLED_APPS in the existing conditional without changing the
token-generator selection.

In `@openwisp_users/tests/test_middlewares.py`:
- Around line 121-133: Update
test_expired_password_session_bearer_request_not_blocked_for_existing_token to
authenticate the expired admin in the client before issuing the Bearer-token
request, while preserving the existing token setup and expected 200 response.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: af6efe59-259f-4421-860b-29bc0bf75593

📥 Commits

Reviewing files that changed from the base of the PR and between 393fee6 and 2b2f53c.

📒 Files selected for processing (9)
  • openwisp_users/api/views.py
  • openwisp_users/apps.py
  • openwisp_users/base/forms.py
  • openwisp_users/middleware.py
  • openwisp_users/tests/test_accounts.py
  • openwisp_users/tests/test_api/test_password_reset.py
  • openwisp_users/tests/test_api/test_views.py
  • openwisp_users/tests/test_middlewares.py
  • tests/openwisp2/sample_users/tests.py
📜 Review details
⏰ Context from checks skipped due to timeout. (15)
  • GitHub Check: Python==3.10 | django~=4.2.0
  • GitHub Check: Python==3.12 | django~=5.0.0
  • GitHub Check: Python==3.10 | django~=5.1.0
  • GitHub Check: Python==3.13 | django~=5.2.0
  • GitHub Check: Python==3.11 | django~=5.0.0
  • GitHub Check: Python==3.12 | django~=4.2.0
  • GitHub Check: Python==3.13 | django~=5.1.0
  • GitHub Check: Python==3.10 | django~=5.2.0
  • GitHub Check: Python==3.12 | django~=5.1.0
  • GitHub Check: Python==3.12 | django~=5.2.0
  • GitHub Check: Python==3.10 | django~=5.0.0
  • GitHub Check: Python==3.11 | django~=5.2.0
  • GitHub Check: Python==3.11 | django~=5.1.0
  • GitHub Check: Python==3.11 | django~=4.2.0
  • GitHub Check: Kilo Code Review
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

**/*.py: Mark user-facing strings for translation with Django i18n helpers in Django code
Place imports at the top of the file; only defer imports when necessary (e.g., Django model imports inside functions or methods where the app registry is not yet ready)
Avoid unnecessary blank lines inside function and method bodies
Write comments and docstrings only when they explain why code is shaped a certain way; place comments before the relevant code block instead of scattering them inside it

For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework.

Files:

  • tests/openwisp2/sample_users/tests.py
  • openwisp_users/tests/test_api/test_views.py
  • openwisp_users/tests/test_accounts.py
  • openwisp_users/base/forms.py
  • openwisp_users/apps.py
  • openwisp_users/api/views.py
  • openwisp_users/tests/test_middlewares.py
  • openwisp_users/tests/test_api/test_password_reset.py
  • openwisp_users/middleware.py
🔇 Additional comments (12)
openwisp_users/apps.py (2)

172-174: Passwordless login-by-code is still not marked EXTERNAL (duplicate).

handle_allauth_login only checks sociallogin; passwordless code flows can remain classified as PASSWORD and be blocked after local-password expiration. Add coverage or disregard this only if login-by-code is disabled.


3-7: LGTM!

Also applies to: 18-18, 143-170

openwisp_users/base/forms.py (3)

85-109: from_email is still discarded (duplicate of the previous finding).

save() supplies a custom sender, but send_mail() never forwards it to send_email(). Preserve the sender through the supported email utility mechanism or remove the unused parameter.


99-109: 🎯 Functional Correctness

Verify the html_email_template_name contract.

The argument is accepted but ignored, so callers providing separate text and HTML templates will have email_template_name rendered as HTML. Render html_email_template_name or email_template_name, or remove the parameter if this behavior is intentional.


1-7: LGTM!

Also applies to: 26-45, 52-84

openwisp_users/api/views.py (1)

2-9: LGTM!

Also applies to: 24-25, 36-36, 72-124, 255-268, 331-340

openwisp_users/middleware.py (1)

3-30: LGTM!

Also applies to: 39-46, 50-65, 67-72, 74-88, 89-102, 104-110

openwisp_users/tests/test_api/test_views.py (1)

1-10: LGTM!

Also applies to: 60-80

openwisp_users/tests/test_middlewares.py (1)

1-13: LGTM!

Also applies to: 51-76, 78-120, 135-172

openwisp_users/tests/test_api/test_password_reset.py (1)

1-11: LGTM!

Also applies to: 14-33, 34-70, 71-81, 82-97, 98-133

tests/openwisp2/sample_users/tests.py (1)

19-21: LGTM!

Also applies to: 161-162, 178-178

openwisp_users/tests/test_accounts.py (1)

4-16: LGTM!

Also applies to: 32-46, 49-70, 75-90, 92-98

Comment thread openwisp_users/base/forms.py Outdated
Comment thread openwisp_users/tests/test_accounts.py Outdated
@pandafy
pandafy force-pushed the issues/511-passowrd-expiration-sso branch from 2b2f53c to 16feb3d Compare July 28, 2026 20:07

@kilo-code-bot kilo-code-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CRITICAL: Password expiration check removed from ObtainAuthTokenView without replacement enforcement point

The if user.has_password_expired(): return Response(..., status=403) check was removed from ObtainAuthTokenView.post (line 69) and replaced with return super().post(...) which delegates to dj-rest-auth's serializer. The serializer has no password expiration awareness.

This creates a vulnerability for token-based (Bearer) authentication. The middleware only blocks session-authenticated requests (AUTH_SESSION_KEY in request.session), but token auth is stateless and never sets a session key. As a result, expired-password users can obtain new API tokens via the token endpoint.

The test test_obtain_auth_token_expired_password_rejected passes only because the Django test client uses session auth, hitting the middleware blocker — not because the view itself enforces the check.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread openwisp_users/api/views.py Outdated
@@ -61,6 +69,59 @@ def post(self, request, *args, **kwargs):
return super().post(request, *args, **kwargs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CRITICAL: Password expiration check removed without replacement enforcement point

The if user.has_password_expired(): return Response(..., status=403) check was removed from ObtainAuthTokenView.post and replaced with return super().post(...). The dj-rest-auth serializer has no password expiration awareness.

This creates a vulnerability for token-based (Bearer) authentication. The middleware only blocks session-authenticated requests (AUTH_SESSION_KEY in request.session), but token auth is stateless and never sets a session key. Expired-password users can obtain new API tokens via the token endpoint.

The test test_obtain_auth_token_expired_password_rejected passes only because the Django test client uses session auth, hitting the middleware blocker — not because the view itself enforces the check.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This no longer applies. The latest issue #511 discussion explicitly dropped rejection at token issuance because downstream clients need to obtain a token to complete password recovery. Allowing issuance is intentional. The remaining bug is that an expired session cookie causes the middleware to reject this endpoint before it validates the submitted credentials.

AI-assisted (GPT-5.6 Sol / high), not manually validated.

@kilo-code-bot

kilo-code-bot Bot commented Jul 28, 2026

Copy link
Copy Markdown

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (1 file)
  • openwisp_users/base/models.py
Previous Review Summaries (10 snapshots, latest commit 852e290)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 852e290)

Status: No Issues Found | Recommendation: Merge

Overview

The incremental diff includes a single change: adding app_name = "users" to openwisp_users/api/urls.py. This is a standard Django URL namespacing pattern and is correctly placed at the module level alongside urlpatterns. No issues detected in the changed code.

Files Reviewed (1 file)
  • openwisp_users/api/urls.py

Previous review (commit 02684ef)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (1 file)
  • docs/user/rest-api.rst

Previous review (commit c613dce)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (1 file)
  • docs/user/rest-api.rst

Previous review (commit 6ea4272)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (8 files)
  • openwisp_users/apps.py
  • openwisp_users/auth.py
  • openwisp_users/base/models.py
  • openwisp_users/migrations/0026_fix_duplicate_user_id_email_idx_state.py
  • openwisp_users/migrations/0027_user_last_login_method.py
  • openwisp_users/tests/test_auth.py
  • openwisp_users/tests/test_middlewares.py
  • tests/openwisp2/sample_users/migrations/0001_initial.py

Previous review (commit f1b41cf)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (8 files)
  • openwisp_users/apps.py
  • openwisp_users/auth.py
  • openwisp_users/base/models.py
  • openwisp_users/migrations/0026_fix_duplicate_user_id_email_idx_state.py
  • openwisp_users/migrations/0027_user_last_login_method.py
  • openwisp_users/tests/test_auth.py
  • openwisp_users/tests/test_middlewares.py
  • tests/openwisp2/sample_users/migrations/0001_initial.py

Previous review (commit e43df9b)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (8 files)
  • openwisp_users/apps.py
  • openwisp_users/auth.py
  • openwisp_users/base/models.py
  • openwisp_users/migrations/0026_fix_duplicate_user_id_email_idx_state.py
  • openwisp_users/migrations/0027_user_last_login_method.py
  • openwisp_users/tests/test_auth.py
  • openwisp_users/tests/test_middlewares.py
  • tests/openwisp2/sample_users/migrations/0001_initial.py

Previous review (commit a9baca3)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (4 files)
  • docs/developer/misc-utils.rst
  • docs/user/account-and-password-expiration.rst
  • docs/user/rest-api.rst
  • openwisp_users/tests/test_api/test_password_reset.py

Previous review (commit 5e55d66)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (4 files)
  • docs/developer/misc-utils.rst
  • docs/user/account-and-password-expiration.rst
  • docs/user/rest-api.rst
  • openwisp_users/tests/test_api/test_password_reset.py

Previous review (commit 2076e43)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 0
SUGGESTION 0
Issue Details (click to expand)

CRITICAL

File Line Issue
openwisp_users/api/views.py 69 Password expiration check removed from ObtainAuthTokenView.post without replacement enforcement point — token-based (Bearer) auth can bypass password expiration entirely because the middleware only applies to session-based requests
Files Reviewed (9 files)
  • openwisp_users/api/views.py - 1 issue
  • openwisp_users/middleware.py - 0 issues
  • openwisp_users/apps.py - 0 issues
  • openwisp_users/base/forms.py - 0 issues
  • openwisp_users/tests/test_accounts.py - 0 issues
  • openwisp_users/tests/test_api/test_password_reset.py - 0 issues
  • openwisp_users/tests/test_api/test_views.py - 0 issues
  • openwisp_users/tests/test_middlewares.py - 0 issues
  • tests/openwisp2/sample_users/tests.py - 0 issues

Fix these issues in Kilo Cloud

Previous review (commit 16feb3d)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 0
SUGGESTION 0
Issue Details (click to expand)

CRITICAL

File Line Issue
openwisp_users/api/views.py 69 Password expiration check removed from ObtainAuthTokenView.post without replacement enforcement point — token-based (Bearer) auth can bypass password expiration entirely because the middleware only applies to session-based requests
Files Reviewed (9 files)
  • openwisp_users/api/views.py - 1 issue
  • openwisp_users/middleware.py - 0 issues
  • openwisp_users/apps.py - 0 issues
  • openwisp_users/base/forms.py - 0 issues
  • openwisp_users/tests/test_accounts.py - 0 issues
  • openwisp_users/tests/test_api/test_password_reset.py - 0 issues
  • openwisp_users/tests/test_api/test_views.py - 0 issues
  • openwisp_users/tests/test_middlewares.py - 0 issues
  • tests/openwisp2/sample_users/tests.py - 0 issues

Fix these issues in Kilo Cloud


Reviewed by step-3.7-flash · Input: 69.7K · Output: 2.4K · Cached: 254.7K

@openwisp-companion

Copy link
Copy Markdown

Flake8 F401 Unused Imports

Hello @pandafy,
(Analysis for commit 16feb3d)

The CI failed because of unused imports detected by Flake8.

  • ./openwisp_users/api/views.py: rest_framework.authtoken.models.Token and openwisp_users.auth.password_expired_response_payload are imported but not used.
  • ./openwisp_users/tests/test_api/test_views.py: rest_framework.authtoken.models.Token is imported but not used.

Fix: Remove these unused imports from the specified files.

Test Failure: test_obtain_auth_token_expired_password_rejected

The test test_obtain_auth_token_expired_password_rejected in openwisp_users/tests/test_api/test_views.py is failing because it expects a 403 status code but receives a 200.

Fix: Review the test logic and the corresponding view code to understand why the status code is not as expected. It's likely that the response from the API endpoint is not correctly reflecting the password expiration status, or the test assertion is incorrect.

Geckodriver Version Mismatch

The CI logs indicate a potential compatibility issue between the geckodriver version (0.37.0) and the detected Firefox version (152.0.6). The logs recommend geckodriver 0.37.1 for Firefox 152.*.

Fix: This appears to be an infrastructure issue with the CI environment. The CI has been restarted automatically. If the issue persists, please report it to the CI/CD team.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
openwisp_users/tests/test_api/test_views.py (1)

24-35: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Restore rejection of token issuance for expired passwords.

This test now codifies the opposite of the PR requirement: an expired password must not obtain a REST token. Assert the structured 403/password_expired response and verify that no token is returned.

Proposed test correction
-    def test_obtain_auth_token_expired_password_success(self):
+    def test_obtain_auth_token_expired_password_rejected(self):
         self._create_user(
             username="tester",
             password="tester",
             password_updated=now().date() - timedelta(days=180),
         )
         params = {"username": "tester", "password": "tester"}
         url = reverse("users:user_auth_token")
         response = self.client.post(url, params)
-        self.assertEqual(response.status_code, 200)
-        self.assertIn("token", response.data)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.data["code"], "password_expired")
+        self.assertNotIn("token", response.data)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/tests/test_api/test_views.py` around lines 24 - 35, Update
test_obtain_auth_token_expired_password_success to assert the expired-password
request is rejected with HTTP 403 and the structured password_expired response,
and verify the response does not contain a token. Rename the test to reflect
rejection rather than successful token issuance.
openwisp_users/tests/test_api/test_password_reset.py (1)

74-83: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Compare unknown and known reset responses.

This test only checks a fixed response for the unknown identifier; it never proves that the response matches a real account request. This repeats the previous-review finding. Create a known user, capture its response, clear the outbox, then compare both status and response data.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/tests/test_api/test_password_reset.py` around lines 74 - 83,
Create a known user within
test_reset_request_with_unknown_identifier_is_indistinguishable, submit a
password-reset request for that user, and capture its status and response data.
Clear mail.outbox afterward, submit the unknown-identifier request, and assert
that both status code and response data match the known request while confirming
no email is sent for the unknown identifier.
♻️ Duplicate comments (1)
openwisp_users/base/forms.py (1)

80-94: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not silently discard from_email.

save(..., from_email=...) is still accepted through **kwargs but is never forwarded, so callers lose their requested sender without an error. This repeats the unresolved previous-review finding; preserve the sender contract or reject/handle the argument explicitly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/base/forms.py` around lines 80 - 94, Update the password-reset
form’s save/send_mail flow so an explicitly supplied from_email is not silently
ignored: either forward it through the email-sending path and preserve the
requested sender, or explicitly reject/handle the argument with a clear error.
Use the visible send_mail method and its caller’s **kwargs handling as the
change points.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/user/account-and-password-expiration.rst`:
- Around line 88-102: Update the password-expiration documentation and JSON
example to state that api_password_change_url and api_password_reset_url are
included only when the authentication API is enabled and their corresponding
routes are available; show these fields as conditional rather than universally
present.

In `@docs/user/rest-api.rst`:
- Around line 124-139: The self-service password-change documentation lists
unsupported PUT and PATCH methods. Update the endpoint description and curl
example for the self-service password-change endpoint to use POST, matching its
implemented handler and existing tests.

---

Outside diff comments:
In `@openwisp_users/tests/test_api/test_password_reset.py`:
- Around line 74-83: Create a known user within
test_reset_request_with_unknown_identifier_is_indistinguishable, submit a
password-reset request for that user, and capture its status and response data.
Clear mail.outbox afterward, submit the unknown-identifier request, and assert
that both status code and response data match the known request while confirming
no email is sent for the unknown identifier.

In `@openwisp_users/tests/test_api/test_views.py`:
- Around line 24-35: Update test_obtain_auth_token_expired_password_success to
assert the expired-password request is rejected with HTTP 403 and the structured
password_expired response, and verify the response does not contain a token.
Rename the test to reflect rejection rather than successful token issuance.

---

Duplicate comments:
In `@openwisp_users/base/forms.py`:
- Around line 80-94: Update the password-reset form’s save/send_mail flow so an
explicitly supplied from_email is not silently ignored: either forward it
through the email-sending path and preserve the requested sender, or explicitly
reject/handle the argument with a clear error. Use the visible send_mail method
and its caller’s **kwargs handling as the change points.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: b34bab24-f900-417b-bf03-9881d40d6558

📥 Commits

Reviewing files that changed from the base of the PR and between 16feb3d and 2076e43.

📒 Files selected for processing (15)
  • docs/developer/misc-utils.rst
  • docs/user/account-and-password-expiration.rst
  • docs/user/rest-api.rst
  • docs/user/settings.rst
  • openwisp_users/api/serializers.py
  • openwisp_users/api/views.py
  • openwisp_users/auth.py
  • openwisp_users/backends.py
  • openwisp_users/base/forms.py
  • openwisp_users/tests/test_api/test_password_reset.py
  • openwisp_users/tests/test_api/test_throttling.py
  • openwisp_users/tests/test_api/test_views.py
  • openwisp_users/tests/test_auth.py
  • openwisp_users/tests/test_middlewares.py
  • tests/openwisp2/settings.py
💤 Files with no reviewable changes (1)
  • openwisp_users/api/serializers.py
📜 Review details
⏰ Context from checks skipped due to timeout. (15)
  • GitHub Check: Python==3.11 | django~=5.2.0
  • GitHub Check: Python==3.11 | django~=5.1.0
  • GitHub Check: Python==3.13 | django~=5.2.0
  • GitHub Check: Python==3.12 | django~=5.1.0
  • GitHub Check: Python==3.12 | django~=5.2.0
  • GitHub Check: Python==3.11 | django~=4.2.0
  • GitHub Check: Python==3.12 | django~=4.2.0
  • GitHub Check: Python==3.13 | django~=5.1.0
  • GitHub Check: Python==3.12 | django~=5.0.0
  • GitHub Check: Python==3.11 | django~=5.0.0
  • GitHub Check: Python==3.10 | django~=4.2.0
  • GitHub Check: Python==3.10 | django~=5.2.0
  • GitHub Check: Python==3.10 | django~=5.0.0
  • GitHub Check: Python==3.10 | django~=5.1.0
  • GitHub Check: Kilo Code Review
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

**/*.py: Mark user-facing strings for translation with Django i18n helpers in Django code
Place imports at the top of the file; only defer imports when necessary (e.g., Django model imports inside functions or methods where the app registry is not yet ready)
Avoid unnecessary blank lines inside function and method bodies
Write comments and docstrings only when they explain why code is shaped a certain way; place comments before the relevant code block instead of scattering them inside it

For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework.

Files:

  • openwisp_users/backends.py
  • openwisp_users/auth.py
  • openwisp_users/tests/test_api/test_throttling.py
  • tests/openwisp2/settings.py
  • openwisp_users/base/forms.py
  • openwisp_users/tests/test_api/test_views.py
  • openwisp_users/tests/test_auth.py
  • openwisp_users/tests/test_middlewares.py
  • openwisp_users/api/views.py
  • openwisp_users/tests/test_api/test_password_reset.py
🔇 Additional comments (17)
openwisp_users/auth.py (1)

1-16: LGTM!

Also applies to: 17-31, 33-64

openwisp_users/backends.py (1)

24-24: LGTM!

openwisp_users/tests/test_auth.py (1)

1-11: LGTM!

Also applies to: 14-23, 24-34

openwisp_users/tests/test_middlewares.py (1)

1-13: LGTM!

Also applies to: 51-53, 58-76, 78-103, 105-120, 121-134, 136-145, 147-172, 174-183, 184-194

openwisp_users/tests/test_api/test_views.py (1)

1-9: LGTM!

Also applies to: 63-79

openwisp_users/tests/test_api/test_throttling.py (1)

13-16: LGTM!

docs/developer/misc-utils.rst (2)

166-185: LGTM!


198-207: 🎯 Functional Correctness

Bear in mind the Bearer exception may not hold for mixed auth requests. If this middleware runs before DRF authentication and only checks the session, a request with an expired session cookie plus a valid Bearer token can still be rejected with 403. Narrow the doc to token-only requests or add an explicit Bearer/session bypass.

tests/openwisp2/settings.py (1)

39-39: LGTM!

Also applies to: 115-119

openwisp_users/api/views.py (3)

70-77: 🗄️ Data Integrity & Integration

Verify the custom reset serializer is configured in production.

This view no longer declares serializer_class, while the supplied REST_AUTH["PASSWORD_RESET_SERIALIZER"] override exists only in tests/openwisp2/settings.py. The dj-rest-auth base view resolves its serializer from that setting; without the production equivalent, the endpoint can fall back to a serializer that does not support the documented input identifier. (raw.githubusercontent.com) Restore the explicit binding or ship/document a package-level default and test without the test-only override.


24-24: LGTM!

Also applies to: 95-121, 252-262, 325-334


2-7: 🩺 Stability & Availability

Make optional allauth support consistent across both modules.

Both modules import allauth.account.utils unconditionally, so deployments without allauth can fail during module import before the non-allauth fallback is reached.

  • openwisp_users/api/views.py#L2-L7: guard user_pk_to_url_str or make allauth mandatory.
  • openwisp_users/base/forms.py#L1-L1: apply the same optional-dependency strategy.
openwisp_users/base/forms.py (2)

12-26: LGTM!

Also applies to: 57-78


54-56: 🎯 Functional Correctness

No change needed here. The only in-repo password-reset caller passes a request, so this fallback doesn’t affect the shipped reset flow.

			> Likely an incorrect or invalid review comment.
openwisp_users/tests/test_api/test_password_reset.py (1)

3-27: LGTM!

Also applies to: 29-72, 85-136, 137-176, 179-212

docs/user/rest-api.rst (1)

71-113: LGTM!

docs/user/settings.rst (1)

76-78: LGTM!

Comment thread docs/user/account-and-password-expiration.rst
Comment thread docs/user/rest-api.rst Outdated
@pandafy
pandafy force-pushed the issues/511-passowrd-expiration-sso branch from 5e55d66 to a9baca3 Compare July 29, 2026 16:46
@openwisp-companion

Copy link
Copy Markdown

ReStructuredText Formatting Failure

Hello @pandafy,
(Analysis for commit 5e55d66)

The CI failed because of a ReStructuredText formatting issue in the documentation.

Failure:

  • ERROR: ReStructuredText check failed!
  • File '/home/runner/work/openwisp-users/openwisp-users/docs/user/account-and-password-expiration.rst' could be reformatted.

Fix:

Please reformat the file docs/user/account-and-password-expiration.rst to resolve the ReStructuredText formatting error. You can typically do this by running a documentation linter or formatter, or by manually adjusting the formatting according to ReStructuredText conventions.

@nemesifier nemesifier left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI-assisted review (GPT-5.6 Sol / high). The findings have not been manually validated.

I left the outstanding findings inline. The CodeRabbit observation about the test-only password-reset serializer is correct and remains important. The Kilo and CodeRabbit requests to reject token issuance for expired passwords are stale because the latest issue #511 discussion explicitly dropped that requirement. The login-by-code suggestion is too broad for this PR because Sesame magic links are the supported passwordless path here and are explicitly covered. The HTML CTA comment was correctly withdrawn, but the CTA target still returns 405. Other automated comments addressed by the current head are not repeated.

return super().post(request, *args, **kwargs)


class PasswordResetView(BasePasswordResetView):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

High: This endpoint only uses PasswordResetSerializer because the test project overrides REST_AUTH["PASSWORD_RESET_SERIALIZER"]. A default installation gets dj-rest-auth's serializer, accepts only email, and bypasses the documented username and phone lookup. I would set serializer_class = PasswordResetSerializer directly on this view and test it without the test-only override.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@nemesifier this is how it's done in openwisp-radius right now.

https://github.com/openwisp/openwisp-radius/blob/6c4baf41e0216bca15635fac554c71fe8db83979/tests/openwisp2/settings.py#L308

And, this is something which will be configured by either ansible-openwisp2 and docker-openwisp

)


class PasswordChangeView(BasePasswordChangeView):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

High: The old password is not always verified as the docstring claims. dj-rest-auth defaults OLD_PASSWORD_FIELD_ENABLED to False, removes old_password, and allows an authenticated session or Bearer token to replace the password. The tests hide this with a test-only setting. I would use an OpenWISP-owned serializer which always requires and verifies old_password.

Comment thread openwisp_users/auth.py Outdated
if method is not None:
return method
user = user if user is not None else getattr(request, "user", None)
return getattr(user, "last_login_method", "") or PASSWORD

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

High: An unmarked session inherits the user's global last_login_method. An external login in another browser can therefore make an older password-authenticated session bypass expiration, while a later password login can misclassify an external session. When a request has no session marker, I would default that request to PASSWORD and reserve the persisted fallback for callers with no request. Please cover two independent sessions.

Comment thread openwisp_users/api/views.py Outdated
touching token generation or email sending.
"""
uid = user_pk_to_url_str(user)
confirm_path = reverse("users:rest_password_reset_confirm")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

High: The email tells the user to click a reset button, but this target only accepts POST. Clicking the generated URL performs GET and returns 405, so the default recovery flow is not usable. Please point the CTA to a GET-capable password selection page, or provide one, and make the test follow the emailed URL.

Comment thread openwisp_users/middleware.py Outdated
"account_reset_password_done",
"account_reset_password_from_key",
"account_reset_password_from_key_done",
"change_password",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

High: I do not think this endpoint can be exempted. It lets a superuser or organization manager change another user's password without presenting their own old password, so an expired privileged session can continue account takeover operations. Please remove this exemption and keep only the self-service recovery route exempt.

local login.
"""

throttle_classes = [AuthRateThrottle]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Medium: AuthRateThrottle subclasses AnonRateThrottle, which returns no cache key for authenticated requests. Any logged-in user can bypass this limit and send unlimited reset emails. The same ineffective throttle is used by confirm and password change. Please use throttles that cover authenticated requests and add authenticated-request coverage.


def __call__(self, request):
session_authenticated_before = AUTH_SESSION_KEY in request.session
if session_authenticated_before and self._is_expired_password_session(request):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Medium: This check runs before DRF authenticates an accompanying Bearer token. A request with an expired session cookie and a valid existing key is rejected even though protected API views prioritize BearerAuthentication. The current test has no session and misses this case. Please cover mixed credentials and ensure only a successfully authenticated Bearer request bypasses the session restriction.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@nemesifier I don't think this is a real concern.

API_PASSWORD_CHANGE_URL_NAME,
"users:rest_password_reset",
"users:rest_password_reset_confirm",
]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Medium: The final issue discussion intentionally allows token issuance with an expired password, but an expired session cookie still blocks users:user_auth_token here before submitted credentials reach the view. I would exempt that route and add a regression test where the client already has an expired session.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@nemesifier, I think the current implementation is sane. If we can block token issuance with an expired password we should block it.

In openwisp-radius, it is more of a problem because the downstream application (WiFi Login Pages) will use the Bearer Token to change the password and it wouldn't be able to do so without obtaining token.

However, the approach is contradicting and make the maintenance harder.

A user that has once gained the Token can perform all operations with the REST API even with an expired password.

Comment thread docs/user/rest-api.rst
{"token": "7a2e1d3d008253c123c61d56741003db5a194256"}

.. _authenticating_rest_api:
.. _rest_password_reset:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Medium: This replaced the existing authenticating_rest_api anchor and Bearer-token usage section instead of adding the recovery documentation after it. docs/developer/django-rest-framework-utils.rst still references the removed anchor. Please restore that section and keep the new material alongside it.

Comment thread docs/user/rest-api.rst Outdated

.. code-block:: text

PUT /api/v1/users/user/password/change/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Low: This summary still advertises PUT and PATCH, but the view only implements POST. Please make it match the corrected detailed section.

@openwisp-companion

Copy link
Copy Markdown
[
  {"line": 2026, "col": 0, "message": "Imports are incorrectly sorted and/or formatted."},
  {"line": 2026, "col": 0, "message": "Isort check failed! Hint: did you forget to run openwisp-qa-format?"}
]

CI Failure Analysis

Hello @pandafy,
(Analysis for commit f1b41cf)

The CI has failed due to an import sorting and formatting issue in the tests/test_api/test_api.py file.

Failure: Code Style/QA (Isort)

Fix:
Please run the command openwisp-qa-format to automatically fix the import order and formatting.

@pandafy
pandafy force-pushed the issues/511-passowrd-expiration-sso branch from f1b41cf to 6ea4272 Compare July 30, 2026 14:07
pandafy added 2 commits July 30, 2026 19:44
Instead of storing which method the user has logged in with, we now
only store if the user has logged in with password.
@openwisp-companion

Copy link
Copy Markdown

The CI is failing due to transient infrastructure issues (not related to your code). I have restarted the failed jobs automatically (1/3).

@openwisp-companion

Copy link
Copy Markdown

The CI is failing due to transient infrastructure issues (not related to your code). I have restarted the failed jobs automatically (1/3).

@pandafy

pandafy commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review!

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

@pandafy I will perform a full review of all changes in this pull request.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot removed the bug label Jul 31, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@openwisp_users/middleware.py`:
- Around line 39-43: Ensure expired-password users cannot obtain REST
authentication tokens: either remove users:user_auth_token from the
password-change exemption list or add a has_password_expired() check in
ObtainAuthTokenView.post before token creation. Update the related success test
to cover the expired-password rejection while preserving normal token issuance
for valid credentials.

In `@openwisp_users/tests/test_api/test_views.py`:
- Around line 25-36: Update test_obtain_auth_token_expired_password_success to
expect rejection for the expired password instead of a successful token
response: assert the documented non-200 status and verify response.data contains
the password-recovery link, removing the token assertion.

In `@openwisp_users/tests/test_middlewares.py`:
- Around line 135-146: Update
test_expired_password_session_can_obtain_auth_token and the underlying
users:user_auth_token flow so password-authenticated users with expired
passwords cannot receive new tokens: expect HTTP 403 and a response code of
"password_expired". Preserve validity for existing Bearer tokens while enforcing
the expiration response at token issuance.

In `@tests/openwisp2/sample_users/migrations/0001_initial.py`:
- Around line 36-52: The sample_users migration history must remain unchanged:
restore last_login_method in 0001_initial.py and add a new append-only migration
after 0006_alter_organizationuser_is_admin.py that transitions the schema to
password_based_token, preserving compatibility for databases with
already-applied migrations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3aaebcbc-20db-4981-9a52-6f6a403c607d

📥 Commits

Reviewing files that changed from the base of the PR and between 3b6653d and df55328.

📒 Files selected for processing (30)
  • docs/developer/misc-utils.rst
  • docs/user/account-and-password-expiration.rst
  • docs/user/rest-api.rst
  • docs/user/settings.rst
  • openwisp_users/admin.py
  • openwisp_users/api/serializers.py
  • openwisp_users/api/urls.py
  • openwisp_users/api/views.py
  • openwisp_users/apps.py
  • openwisp_users/auth.py
  • openwisp_users/backends.py
  • openwisp_users/base/forms.py
  • openwisp_users/base/models.py
  • openwisp_users/middleware.py
  • openwisp_users/migrations/0026_user_password_based_token.py
  • openwisp_users/settings.py
  • openwisp_users/templates/custom_password_reset_email.html
  • openwisp_users/templates/custom_password_reset_email.txt
  • openwisp_users/tests/test_accounts.py
  • openwisp_users/tests/test_admin.py
  • openwisp_users/tests/test_api/test_api.py
  • openwisp_users/tests/test_api/test_password_reset.py
  • openwisp_users/tests/test_api/test_throttling.py
  • openwisp_users/tests/test_api/test_views.py
  • openwisp_users/tests/test_auth.py
  • openwisp_users/tests/test_middlewares.py
  • requirements.txt
  • tests/openwisp2/sample_users/migrations/0001_initial.py
  • tests/openwisp2/sample_users/tests.py
  • tests/openwisp2/settings.py
📜 Review details
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

**/*.py: Mark user-facing strings for translation with Django i18n helpers in Django code
Place imports at the top of the file; only defer imports when necessary (e.g., Django model imports inside functions or methods where the app registry is not yet ready)
Avoid unnecessary blank lines inside function and method bodies
Write comments and docstrings only when they explain why code is shaped a certain way; place comments before the relevant code block instead of scattering them inside it

For Django pull requests, ensure all user-facing strings are marked as translatable using the Django i18n framework.

Files:

  • openwisp_users/migrations/0026_user_password_based_token.py
  • openwisp_users/tests/test_api/test_throttling.py
  • openwisp_users/admin.py
  • openwisp_users/backends.py
  • openwisp_users/base/models.py
  • tests/openwisp2/sample_users/migrations/0001_initial.py
  • openwisp_users/base/forms.py
  • openwisp_users/tests/test_auth.py
  • openwisp_users/api/urls.py
  • openwisp_users/tests/test_api/test_views.py
  • openwisp_users/apps.py
  • openwisp_users/auth.py
  • openwisp_users/tests/test_api/test_api.py
  • openwisp_users/tests/test_accounts.py
  • openwisp_users/api/serializers.py
  • openwisp_users/tests/test_middlewares.py
  • tests/openwisp2/settings.py
  • openwisp_users/middleware.py
  • openwisp_users/tests/test_admin.py
  • openwisp_users/api/views.py
  • openwisp_users/tests/test_api/test_password_reset.py
  • tests/openwisp2/sample_users/tests.py
  • openwisp_users/settings.py
🧠 Learnings (2)
📚 Learning: 2026-06-19T00:08:14.892Z
Learnt from: nemesifier
Repo: openwisp/openwisp-users PR: 530
File: openwisp_users/migrations/0023_add_auth_token_permissions_to_admins.py:9-12
Timestamp: 2026-06-19T00:08:14.892Z
Learning: When working on migrations in openwisp_users that grant permissions relying on Django REST Framework’s authtoken app, treat `rest_framework.authtoken` as a required dependency: keep explicit migration dependencies (e.g., depend on `('authtoken', '0004_alter_tokenproxy_options')`) so the permission-grant migration runs only after the relevant authtoken migration has been applied. Any `apps.is_installed('rest_framework.authtoken')` checks elsewhere should be reviewed as defensive guards only (to avoid crashes in unusual environments), not as evidence that authtoken is optional.

Applied to files:

  • openwisp_users/migrations/0026_user_password_based_token.py
📚 Learning: 2026-06-19T00:08:15.922Z
Learnt from: nemesifier
Repo: openwisp/openwisp-users PR: 530
File: tests/openwisp2/sample_users/migrations/0004_default_groups.py:19-22
Timestamp: 2026-06-19T00:08:15.922Z
Learning: When reviewing Django migrations in these openwisp-users migration directories, treat `rest_framework.authtoken` as a required dependency (not optional). If a migration declares a hard `dependencies` entry on `("authtoken", "0004_alter_tokenproxy_options")`, consider it intentional to guarantee correct migration ordering—do not suggest making the dependency conditional or optional. Likewise, if corresponding code uses `apps.is_installed("rest_framework.authtoken")` in models/admin, treat that as defensive guarding only (not as support for running without `authtoken`).

Applied to files:

  • openwisp_users/migrations/0026_user_password_based_token.py
  • tests/openwisp2/sample_users/migrations/0001_initial.py
🪛 ast-grep (0.45.0)
openwisp_users/api/serializers.py

[warning] 23-23: Loading a Keras model from an untrusted file can execute arbitrary code via Lambda layers or custom objects. Load only trusted models and avoid deserializing custom objects from untrusted sources.
Context: load_model("openwisp_users", "Group")
Note: [CWE-502] Deserialization of Untrusted Data.

(keras-load-model-python)

🪛 HTMLHint (1.9.2)
openwisp_users/templates/custom_password_reset_email.html

[error] 1-1: Doctype must be declared before any non-comment content.

(doctype-first)

🔇 Additional comments (33)
openwisp_users/admin.py (1)

228-228: LGTM!

openwisp_users/tests/test_api/test_views.py (3)

1-10: LGTM!


38-47: LGTM!


71-91: LGTM!

openwisp_users/auth.py (1)

1-90: LGTM!

openwisp_users/apps.py (1)

3-18: LGTM!

Also applies to: 143-176

openwisp_users/tests/test_admin.py (1)

10-28: LGTM!

Also applies to: 182-182, 393-393, 429-429, 454-454, 498-498, 847-847, 891-891, 923-923, 951-951, 1019-1019, 2061-2061, 2152-2152, 2192-2192, 2218-2218, 2474-2479

openwisp_users/tests/test_auth.py (1)

1-104: LGTM!

openwisp_users/tests/test_middlewares.py (1)

1-133: LGTM!

Also applies to: 148-209

openwisp_users/settings.py (1)

62-73: LGTM!

openwisp_users/templates/custom_password_reset_email.txt (1)

1-8: LGTM!

docs/user/rest-api.rst (1)

92-160: LGTM!

Also applies to: 177-197

docs/user/settings.rst (1)

76-78: LGTM!

openwisp_users/base/models.py (1)

79-94: LGTM!

openwisp_users/migrations/0026_user_password_based_token.py (1)

1-31: LGTM!

openwisp_users/tests/test_accounts.py (1)

4-19: LGTM!

Also applies to: 34-99

openwisp_users/middleware.py (1)

3-30: LGTM!

Also applies to: 44-110

docs/developer/misc-utils.rst (1)

166-235: LGTM!

Also applies to: 248-264

docs/user/account-and-password-expiration.rst (1)

77-104: LGTM!

openwisp_users/api/urls.py (1)

49-70: LGTM!

tests/openwisp2/sample_users/tests.py (1)

19-34: LGTM!

Also applies to: 153-164, 176-178

openwisp_users/api/views.py (2)

86-96: serializer_class still relies on global REST_AUTH settings, not an explicit binding.

Neither PasswordResetView nor PasswordChangeView sets serializer_class explicitly. Both endpoints only pick up PasswordResetSerializer/PasswordChangeSerializer because the test project configures REST_AUTH["PASSWORD_RESET_SERIALIZER"]/REST_AUTH["PASSWORD_CHANGE_SERIALIZER"]. A default installation without that override falls back to dj-rest-auth's stock serializers: an email-only reset form, and a password-change form where old_password is unverified by default (OLD_PASSWORD_FIELD_ENABLED defaults to False).

A maintainer previously acknowledged this pattern (matching openwisp-radius) and stated it is meant to be configured downstream by ansible-openwisp2/docker-openwisp. Re-flagging since the underlying code is unchanged from the earlier review; downstream projects that don't apply that configuration silently lose the multi-identifier reset and old-password verification.

Also applies to: 268-275


93-93: AuthRateThrottle does not throttle authenticated requests on these endpoints.

AuthRateThrottle subclasses AnonRateThrottle, whose get_cache_key returns None for authenticated requests. PasswordChangeView (line 273) only accepts authenticated requests, so its throttle is inert for every request. The same gap applies to PasswordResetView (line 93) and PasswordResetConfirmView (line 121) when hit by a logged-in user, letting any authenticated account send unlimited reset emails or brute-force confirm tokens without rate limiting.

Re-flagging a previously raised, still-unresolved concern.

Also applies to: 121-121, 273-273

openwisp_users/backends.py (1)

24-24: LGTM!

requirements.txt (1)

4-4: LGTM!

tests/openwisp2/settings.py (1)

39-39: LGTM!

openwisp_users/api/serializers.py (2)

483-528: LGTM!


531-544: LGTM!

openwisp_users/base/forms.py (1)

1-109: LGTM!

openwisp_users/templates/custom_password_reset_email.html (1)

1-11: LGTM!

openwisp_users/tests/test_api/test_password_reset.py (1)

1-241: LGTM!

openwisp_users/tests/test_api/test_throttling.py (1)

13-16: LGTM!

openwisp_users/tests/test_api/test_api.py (1)

439-461: 🗄️ Data Integrity & Integration

No target-user check is needed in this route. PasswordExpirationMiddleware exempts users:rest_password_change, not users:change_password; this test remains subject to expiration blocking.

			> Likely an incorrect or invalid review comment.

Comment on lines +39 to 43
API_PASSWORD_CHANGE_URL_NAME,
"users:rest_password_reset",
"users:rest_password_reset_confirm",
"users:user_auth_token",
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline openwisp_users/api/views.py --items all
rg -n -C 12 \
  'def obtain_auth_token\b|has_password_expired|password_expired|record_password_based_token' \
  openwisp_users/api/views.py openwisp_users/tests/test_api

Repository: openwisp/openwisp-users

Length of output: 14613


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- openwisp_users/api/views.py ---'
sed -n '51,85p' openwisp_users/api/views.py

printf '%s\n' '--- openwisp_users/tests/test_api/test_views.py ---'
sed -n '14,50p' openwisp_users/tests/test_api/test_views.py

printf '%s\n' '--- expiration-related implementations and tests ---'
rg -n -C 10 \
  'password_updated|password_expired|password_expiration|PASSWORD_EXPIRATION|record_password_based_token|authenticate\(' \
  openwisp_users --glob '*.py'

Repository: openwisp/openwisp-users

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

view = Path("openwisp_users/api/views.py").read_text()
tests = Path("openwisp_users/tests/test_api/test_views.py").read_text()

post = re.search(
    r"class ObtainAuthTokenView\b.*?\n(?=class |\Z)", view, re.S
)
assert post, "ObtainAuthTokenView was not found"
post_text = post.group(0)

expired_test = re.search(
    r"def test_obtain_auth_token_expired_password_success\(self\):.*?(?=\n    def |\Z)",
    tests,
    re.S,
)
assert expired_test, "Expired-password token test was not found"

print("ObtainAuthTokenView.post calls serializer.is_valid before Token.objects.get_or_create.")
print("ObtainAuthTokenView.post checks has_password_expired:", "has_password_expired" in post_text)
print("Expired-password test expects status 200:", "assertEqual(response.status_code, 200)" in expired_test.group(0))
print("Expired-password test expects a token:", '"token" in response.data' in expired_test.group(0))
PY

Repository: openwisp/openwisp-users

Length of output: 400


Reject expired passwords before issuing REST tokens. ObtainAuthTokenView.post has no has_password_expired() check and creates a token after credential validation. Remove users:user_auth_token from the exemption or add the check in the view, and update the success test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/middleware.py` around lines 39 - 43, Ensure expired-password
users cannot obtain REST authentication tokens: either remove
users:user_auth_token from the password-change exemption list or add a
has_password_expired() check in ObtainAuthTokenView.post before token creation.
Update the related success test to cover the expired-password rejection while
preserving normal token issuance for valid credentials.

Comment on lines +25 to +36
@patch.object(app_settings, "USER_PASSWORD_EXPIRATION", 10)
def test_obtain_auth_token_expired_password_success(self):
self._create_user(
username="tester",
password="tester",
password_updated=now().date() - timedelta(days=180),
)
params = {"username": "tester", "password": "tester"}
url = reverse("users:user_auth_token")
response = self.client.post(url, params)
self.assertEqual(response.status_code, 200)
self.assertIn("token", response.data)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Assert rejection for an expired local-password token request.

This test backdates password_updated by 180 days while USER_PASSWORD_EXPIRATION is 10 days. It then submits username/password credentials and requires a 200 response with a token. The PR objective requires REST token issuance to be rejected when the password is expired. Change this test to assert the documented rejection response and recovery link.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/tests/test_api/test_views.py` around lines 25 - 36, Update
test_obtain_auth_token_expired_password_success to expect rejection for the
expired password instead of a successful token response: assert the documented
non-200 status and verify response.data contains the password-recovery link,
removing the token assertion.

Comment on lines +135 to +146
@patch.object(app_settings, "STAFF_USER_PASSWORD_EXPIRATION", 10)
def test_expired_password_session_can_obtain_auth_token(self):
# the final #511 decision allows token issuance even with an
# expired password, so a browser holding an expired-password
# session must still be able to reach the token endpoint.
self._login_expired_admin()
response = self.client.post(
reverse("users:user_auth_token"),
data={"username": "admin", "password": "tester"},
)
self.assertEqual(response.status_code, 200)
self.assertIn("token", response.json())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Reject token issuance for an expired password.

This test accepts a new token after password expiration. A newly issued Bearer token bypasses the expiration restriction for a password-authenticated user.

Make the token endpoint return the password-expired response. Update this test to expect HTTP 403 and code == "password_expired". Existing Bearer tokens can remain valid.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openwisp_users/tests/test_middlewares.py` around lines 135 - 146, Update
test_expired_password_session_can_obtain_auth_token and the underlying
users:user_auth_token flow so password-authenticated users with expired
passwords cannot receive new tokens: expect HTTP 403 and a response code of
"password_expired". Preserve validity for existing Bearer tokens while enforcing
the expiration response at token issuance.

Comment on lines +36 to +52
(
"password_based_token",
models.BooleanField(
blank=True,
default=None,
help_text=(
"Indicates whether the last authentication token was"
" obtained using the local password. When false, the"
" token came from an external method (eg: SSO, SAML)"
" and password expiration is not enforced for it. None"
" means no token has been issued for this user since"
" this feature was introduced."
),
null=True,
verbose_name="password based token",
),
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -t f . tests/openwisp2/sample_users/migrations
rg -n -C 4 'password_based_token|dependencies\s*=' tests/openwisp2/sample_users/migrations

Repository: openwisp/openwisp-users

Length of output: 4473


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- migration files ---'
git ls-files tests/openwisp2/sample_users/migrations
printf '%s\n' '--- targeted diff ---'
git diff -- tests/openwisp2/sample_users/migrations/0001_initial.py tests/openwisp2/sample_users/migrations
printf '%s\n' '--- field definitions and migration references ---'
rg -n -C 5 'password_based_token|class Migration|dependencies|operations' tests openwisp_users openwisp_users 2>/dev/null || true
printf '%s\n' '--- latest sample_users migration dependencies ---'
for f in tests/openwisp2/sample_users/migrations/*.py; do
  echo "### $f"
  sed -n '1,80p' "$f"
done

Repository: openwisp/openwisp-users

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- repository state ---'
git status --short
git log -2 --oneline -- tests/openwisp2/sample_users/migrations/0001_initial.py
printf '%s\n' '--- current migration field ---'
sed -n '18,65p' tests/openwisp2/sample_users/migrations/0001_initial.py
printf '%s\n' '--- parent migration field ---'
if git rev-parse --verify HEAD^ >/dev/null 2>&1; then
  git show HEAD^:tests/openwisp2/sample_users/migrations/0001_initial.py 2>/dev/null |
    sed -n '18,65p' || true
fi
printf '%s\n' '--- sample_users migration graph ---'
for f in tests/openwisp2/sample_users/migrations/*.py; do
  printf '### %s\n' "$f"
  rg -n '^(class Migration|    dependencies|        \("sample_users"|    operations|            migrations\.(AddField|CreateModel|AlterField)|            name=)' "$f" || true
done

Repository: openwisp/openwisp-users

Length of output: 5479


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for rev in e43df9b 02684ef; do
  printf '%s\n' "--- $rev summary ---"
  git show --stat --oneline --no-renames "$rev"
  printf '%s\n' "--- $rev affected paths ---"
  git diff-tree --no-commit-id --name-status -r "$rev"
  printf '%s\n' "--- $rev migration diff ---"
  git show --format= --no-ext-diff -- tests/openwisp2/sample_users/migrations/0001_initial.py "$rev" 2>/dev/null || true
done

printf '%s\n' '--- migration history for sample_users ---'
git log --all --oneline --follow -- tests/openwisp2/sample_users/migrations/0001_initial.py

Repository: openwisp/openwisp-users

Length of output: 4434


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for rev in e43df9b 02684ef; do
  printf '%s\n' "--- $rev sample migration diff ---"
  git show --format= --no-ext-diff "$rev" -- tests/openwisp2/sample_users/migrations/0001_initial.py
  printf '%s\n' "--- $rev user migration diff ---"
  git show --format= --no-ext-diff "$rev" -- openwisp_users/migrations
done

printf '%s\n' '--- sample_users usage and migration configuration ---'
rg -n -C 3 'sample_users|MIGRATION|MIGRATIONS|migrate|migration' tests/openwisp2 tests -g '*.py' -g '*.ini' -g '*.cfg' -g '*.toml' -g '*.yml' -g '*.yaml' | head -n 300

Repository: openwisp/openwisp-users

Length of output: 32564


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
import pathlib
import subprocess

root = pathlib.Path("tests/openwisp2/sample_users/migrations")

def text_at(revision, path):
    if revision == "WORKTREE":
        return pathlib.Path(path).read_text()
    return subprocess.check_output(["git", "show", f"{revision}:{path}"], text=True)

def user_fields(source):
    tree = ast.parse(source)
    for node in ast.walk(tree):
        if isinstance(node, ast.Call) and getattr(node.func, "attr", None) == "CreateModel":
            for kw in node.keywords:
                if kw.arg == "name" and isinstance(kw.value, ast.Constant) and kw.value.value == "User":
                    fields = next(k.value for k in node.keywords if k.arg == "fields")
                    return [item.elts[0].value for item in fields if isinstance(item, ast.Tuple)]
    raise RuntimeError("User CreateModel not found")

before = user_fields(text_at("e43df9b", "tests/openwisp2/sample_users/migrations/0001_initial.py"))
after = user_fields(text_at("WORKTREE", "tests/openwisp2/sample_users/migrations/0001_initial.py"))
migration_names = sorted(p.stem for p in root.glob("[0-9][0-9][0-9][0-9]_*.py"))

print("0001 at e43df9b:", {name: name in before for name in ("last_login_method", "password_based_token")})
print("0001 at current:", {name: name in after for name in ("last_login_method", "password_based_token")})
print("sample_users migrations:", migration_names)
print("post-0001 migration contains password_based_token:",
      any("password_based_token" in p.read_text() for p in root.glob("[0-9][0-9][0-9][0-9]_*.py") if p.name != "0001_initial.py"))
PY

Repository: openwisp/openwisp-users

Length of output: 318


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
import pathlib
import subprocess

root = pathlib.Path("tests/openwisp2/sample_users/migrations")

def text_at(revision, path):
    if revision == "WORKTREE":
        return pathlib.Path(path).read_text()
    return subprocess.check_output(["git", "show", f"{revision}:{path}"], text=True)

def user_fields(source):
    tree = ast.parse(source)
    for node in ast.walk(tree):
        if not (isinstance(node, ast.Call) and getattr(node.func, "attr", None) == "CreateModel"):
            continue
        name_kw = next((k for k in node.keywords if k.arg == "name"), None)
        fields_kw = next((k for k in node.keywords if k.arg == "fields"), None)
        if (
            name_kw
            and isinstance(name_kw.value, ast.Constant)
            and name_kw.value.value == "User"
            and fields_kw
            and isinstance(fields_kw.value, ast.List)
        ):
            return [
                item.elts[0].value
                for item in fields_kw.value.elts
                if isinstance(item, ast.Tuple)
                and item.elts
                and isinstance(item.elts[0], ast.Constant)
            ]
    raise RuntimeError("User CreateModel not found")

before = user_fields(text_at("e43df9b", "tests/openwisp2/sample_users/migrations/0001_initial.py"))
after = user_fields(text_at("WORKTREE", "tests/openwisp2/sample_users/migrations/0001_initial.py"))
migration_names = sorted(p.stem for p in root.glob("[0-9][0-9][0-9][0-9]_*.py"))

print("0001 at e43df9b:", {name: name in before for name in ("last_login_method", "password_based_token")})
print("0001 at current:", {name: name in after for name in ("last_login_method", "password_based_token")})
print("sample_users migrations:", migration_names)
print(
    "post-0001 migration contains password_based_token:",
    any(
        "password_based_token" in p.read_text()
        for p in root.glob("[0-9][0-9][0-9][0-9]_*.py")
        if p.name != "0001_initial.py"
    ),
)
PY

Repository: openwisp/openwisp-users

Length of output: 490


Preserve the sample_users migration history.

0001_initial.py replaces last_login_method with password_based_token. Django does not rerun an applied migration, so existing databases can lack password_based_token. Keep 0001_initial.py unchanged and add an append-only migration after 0006_alter_organizationuser_is_admin.py to transition the field.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/openwisp2/sample_users/migrations/0001_initial.py` around lines 36 -
52, The sample_users migration history must remain unchanged: restore
last_login_method in 0001_initial.py and add a new append-only migration after
0006_alter_organizationuser_is_admin.py that transitions the schema to
password_based_token, preserving compatibility for databases with
already-applied migrations.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] Password expiration blocks SSO and REST recovery

2 participants