⚠️ core issue, maintainers only.
Describe the bug
I initially focused on the REST API behavior of password expiration and considered allowing REST requests to continue when a user's password had expired. After reviewing the complete authentication flow, that approach would leave a security gap: anyone who obtained an old but valid password could use the token endpoint to obtain the user's API key.
The proposed solution is therefore to enforce password expiration according to the authentication method used by the current request.
request.user.backend is not sufficient to identify the authentication method. Django uses that attribute temporarily during authentication, and the backend stored in the session identifies how Django reloads the user rather than whether the user authenticated with a password, OAuth, or SAML. The login method must therefore be tracked explicitly when authentication completes.
Steps To Reproduce
- Enable password expiration.
- Authenticate through a password, OAuth, SAML, passwordless, password-based Django session, or existing Bearer API key flow.
- Observe the behavior described below.
Expected behavior
- Password-authenticated browser sessions must be restricted when the password expires and must be directed to a password update or reset flow.
- OAuth and SAML sessions must not be restricted by local password expiration.
- Passwordless authentication methods must not be restricted by local password expiration.
- Requests to obtain a REST API token with an expired password must be rejected before an API key is returned or created.
- Existing Bearer API keys must remain valid. They are independent credentials and may have been created by an administrator or before the password expired.
- REST requests authenticated through a password-based Django session must be rejected with a JSON response rather than redirected to an HTML page.
- REST requests authenticated with an existing Bearer key must continue to work according to the API-key policy above.
OpenWISP RADIUS SAML
OpenWISP RADIUS has a deployed SAML integration based on djangosaml2. This integration predates allauth SAML support and is used for WiFi access through providers such as Azure AD, SPID, and university or municipal identity providers.
This SAML flow does not use allauth's social-login pipeline. djangosaml2 authenticates the user, calls Django auth.login(request, user), and then runs the RADIUS post-login hook. OpenWISP Users must therefore expose a public helper for recording a completed external login, for example:
set_authentication_method(request, "external")
The RADIUS SAML post-login hook should call this helper after successful authentication. The helper should write the same session marker used by OpenWISP Users for allauth OAuth and SAML logins. This keeps OpenWISP Users independent of RADIUS and djangosaml2, while allowing deployed RADIUS SAML sessions to bypass local password expiration.
The existing RADIUS SAML login, organization provisioning, captive portal redirect, and token behavior must remain unchanged. The related RADIUS work is tracked in openwisp/openwisp-radius#754.
REST recovery
When a REST request is blocked because of an expired password, the response should provide machine-readable information and recovery links, for example:
{
"detail": "Your password has expired. Update it to continue.",
"code": "password_expired",
"web_password_change_url": "https://example.com/accounts/password/change/",
"api_password_reset_url": "https://example.com/api/v1/users/password/reset/"
}
The REST recovery link must be usable without first obtaining an API key. OpenWISP Users should provide these global endpoints:
POST /api/v1/users/password/reset/
POST /api/v1/users/password/reset/confirm/
The reset request should accept an email address, username, or phone number in the input field. Unknown identifiers should return the same successful response as known identifiers to avoid account enumeration. The confirmation endpoint should accept uid, token, new_password1, and new_password2.
OpenWISP RADIUS already provides organization-scoped password reset endpoints. Their implementation can be generalized in OpenWISP Users while preserving the existing RADIUS URLs, payloads, responses, settings, extension points, and import paths for backward compatibility. This is required by clients including OpenWISP WiFi Login Pages and OpenWISP Subscriptions.
Additional security fix
The RADIUS password-reset confirmation view skips organization validation for JSON requests. It currently reads confirmation data from request.POST before performing its organization-membership check. JSON requests populate request.data instead, so the membership check is skipped. The generalized implementation must validate the decoded user against the requested organization for both form-encoded and JSON requests. The existing RADIUS endpoint should receive a regression test and compatibility fix.
Screenshots
Not applicable.
System Informatioon:
- OS: Linux
- Python Version: Python 3.12
- Django Version: Django 4.2 through 5.2 are supported by the project
- Browser and Browser Version (if applicable): Not applicable
Implementation outline
- Track completed password, OAuth, and SAML authentication methods explicitly.
- Enforce expiration only for password-authenticated sessions.
- Reject expired passwords at REST token issuance.
- Return structured JSON responses for blocked REST session requests.
- Add global REST password reset and confirmation endpoints to OpenWISP Users.
- Keep the existing OpenWISP RADIUS reset endpoints as compatibility wrappers.
- Add regression tests for password, OAuth/SAML, passwordless, session, Bearer, token issuance, reset, and organization-membership behavior.
Describe the bug
I initially focused on the REST API behavior of password expiration and considered allowing REST requests to continue when a user's password had expired. After reviewing the complete authentication flow, that approach would leave a security gap: anyone who obtained an old but valid password could use the token endpoint to obtain the user's API key.
The proposed solution is therefore to enforce password expiration according to the authentication method used by the current request.
request.user.backendis not sufficient to identify the authentication method. Django uses that attribute temporarily during authentication, and the backend stored in the session identifies how Django reloads the user rather than whether the user authenticated with a password, OAuth, or SAML. The login method must therefore be tracked explicitly when authentication completes.Steps To Reproduce
Expected behavior
OpenWISP RADIUS SAML
OpenWISP RADIUS has a deployed SAML integration based on
djangosaml2. This integration predates allauth SAML support and is used for WiFi access through providers such as Azure AD, SPID, and university or municipal identity providers.This SAML flow does not use allauth's social-login pipeline.
djangosaml2authenticates the user, calls Djangoauth.login(request, user), and then runs the RADIUS post-login hook. OpenWISP Users must therefore expose a public helper for recording a completed external login, for example:The RADIUS SAML post-login hook should call this helper after successful authentication. The helper should write the same session marker used by OpenWISP Users for allauth OAuth and SAML logins. This keeps OpenWISP Users independent of RADIUS and
djangosaml2, while allowing deployed RADIUS SAML sessions to bypass local password expiration.The existing RADIUS SAML login, organization provisioning, captive portal redirect, and token behavior must remain unchanged. The related RADIUS work is tracked in openwisp/openwisp-radius#754.
REST recovery
When a REST request is blocked because of an expired password, the response should provide machine-readable information and recovery links, for example:
{ "detail": "Your password has expired. Update it to continue.", "code": "password_expired", "web_password_change_url": "https://example.com/accounts/password/change/", "api_password_reset_url": "https://example.com/api/v1/users/password/reset/" }The REST recovery link must be usable without first obtaining an API key. OpenWISP Users should provide these global endpoints:
The reset request should accept an email address, username, or phone number in the
inputfield. Unknown identifiers should return the same successful response as known identifiers to avoid account enumeration. The confirmation endpoint should acceptuid,token,new_password1, andnew_password2.OpenWISP RADIUS already provides organization-scoped password reset endpoints. Their implementation can be generalized in OpenWISP Users while preserving the existing RADIUS URLs, payloads, responses, settings, extension points, and import paths for backward compatibility. This is required by clients including OpenWISP WiFi Login Pages and OpenWISP Subscriptions.
Additional security fix
The RADIUS password-reset confirmation view skips organization validation for JSON requests. It currently reads confirmation data from
request.POSTbefore performing its organization-membership check. JSON requests populaterequest.datainstead, so the membership check is skipped. The generalized implementation must validate the decoded user against the requested organization for both form-encoded and JSON requests. The existing RADIUS endpoint should receive a regression test and compatibility fix.Screenshots
Not applicable.
System Informatioon:
Implementation outline