diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 43e99798..81a5cc96 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -103,7 +103,9 @@ jobs:
run: yarn start &
- name: Running OpenWISP Radius
- run: cd openwisp-radius && ./tests/manage.py runserver &
+ run: |
+ cp browser-test/local_settings.py openwisp-radius/tests/openwisp2/local_settings.py \
+ && cd openwisp-radius && ./tests/manage.py runserver &
- name: geckodriver/firefox
run: |
diff --git a/browser-test/initialize_data.py b/browser-test/initialize_data.py
index 3436493d..f1ed2bdf 100755
--- a/browser-test/initialize_data.py
+++ b/browser-test/initialize_data.py
@@ -23,6 +23,7 @@ def load_test_data():
# do not initialize data for registration tests
registration_tests = 'register' in sys.argv
create_mobile_verification_org = 'mobileVerification' in sys.argv
+expired_password_tests = 'expiredPassword' in sys.argv
sys.path.insert(0, os.path.join(OPENWISP_RADIUS_PATH, 'tests'))
sys.argv.insert(1, 'browser-test')
@@ -37,6 +38,7 @@ def load_test_data():
sys.exit(1)
from django.contrib.auth import get_user_model
+from django.utils.timezone import now, timedelta
from swapper import load_model
User = get_user_model()
@@ -86,6 +88,22 @@ def load_test_data():
sys.exit(2)
user = User.objects.create_user(
- username=test_user_email, password=test_user_password, email=test_user_email
+ username=test_user_email,
+ password=test_user_password,
+ email=test_user_email
)
OrganizationUser.objects.create(organization=org, user=user)
+User.objects.update(password_updated=now().date())
+
+if expired_password_tests:
+ data = test_data['expiredPasswordUser']
+ expired_password_user = User.objects.create_user(
+ username=data['email'],
+ password=data['password'],
+ email=data['email'],
+ password_updated=now().date()-timedelta(days=180)
+ )
+ OrganizationUser.objects.create(
+ organization=org,
+ user=expired_password_user
+ )
diff --git a/browser-test/local_settings.py b/browser-test/local_settings.py
new file mode 100644
index 00000000..085926ee
--- /dev/null
+++ b/browser-test/local_settings.py
@@ -0,0 +1,2 @@
+OPENWISP_USERS_USER_PASSWORD_EXPIRATION = 1
+OPENWISP_USERS_STAFF_USER_PASSWORD_EXPIRATION = 1
diff --git a/browser-test/password-expired.test.js b/browser-test/password-expired.test.js
new file mode 100644
index 00000000..f50be222
--- /dev/null
+++ b/browser-test/password-expired.test.js
@@ -0,0 +1,91 @@
+import {until} from "selenium-webdriver";
+import {
+ getDriver,
+ getElementByCss,
+ urls,
+ initialData,
+ initializeData,
+ tearDown,
+ successToastSelector,
+} from "./utils";
+
+describe("Selenium tests for expired password flow />", () => {
+ let driver;
+
+ beforeAll(async () => {
+ await initializeData("expiredPassword");
+ driver = await getDriver();
+ }, 30000);
+
+ afterAll(async () => {
+ await tearDown(driver);
+ });
+
+ it("should force user to change password before captive portal login", async () => {
+ // login with original password
+ await driver.get(urls.login);
+ const data = initialData();
+ let username = await getElementByCss(driver, "input#username");
+ username.sendKeys(data.expiredPasswordUser.email);
+ let password = await getElementByCss(driver, "input#password");
+ password.sendKeys(data.expiredPasswordUser.password);
+ let submitBtn = await getElementByCss(driver, "input[type=submit]");
+ submitBtn.click();
+ await driver.wait(until.urlContains("change-password"), 5000);
+ let successToastDiv = await getElementByCss(driver, "div[role=alert]");
+ await driver.wait(until.elementIsVisible(successToastDiv));
+ expect(await successToastDiv.getText()).toEqual("Login successful");
+ const warningToastMessage = await getElementByCss(
+ driver,
+ ".Toastify__toast--warning",
+ );
+ await driver.wait(until.elementIsVisible(warningToastMessage));
+ expect(await warningToastMessage.getText()).toEqual(
+ "Your password has expired, please update it.",
+ );
+
+ // Try visiting the status page, but the user should redirected
+ // back to change password page
+ await driver.get(urls.status);
+ await driver.wait(until.urlContains("change-password"), 5000);
+
+ // changing password
+ await getElementByCss(driver, "div#password-change");
+ const currPassword = await getElementByCss(
+ driver,
+ "input#current-password",
+ );
+ currPassword.sendKeys(data.expiredPasswordUser.password);
+ const newPassword = "newPassword@";
+ const changePassword = await getElementByCss(driver, "input#new-password");
+ changePassword.sendKeys(newPassword);
+ const changePasswordConfirm = await getElementByCss(
+ driver,
+ "input#password-confirm",
+ );
+ changePasswordConfirm.sendKeys(newPassword);
+ submitBtn = await getElementByCss(driver, "input[type=submit]");
+ submitBtn.click();
+ await getElementByCss(driver, "div#status");
+ successToastDiv = await getElementByCss(driver, successToastSelector);
+ await driver.wait(until.elementIsVisible(successToastDiv));
+ expect(await successToastDiv.getText()).toEqual(
+ "Password updated successfully",
+ );
+
+ // login with new password
+ await driver.manage().deleteAllCookies();
+ await driver.get(urls.login);
+ await driver.wait(until.urlContains("login"), 5000);
+ username = await getElementByCss(driver, "input#username");
+ username.sendKeys(data.expiredPasswordUser.email);
+ password = await getElementByCss(driver, "input#password");
+ password.sendKeys(newPassword);
+ submitBtn = await getElementByCss(driver, "input[type=submit]");
+ submitBtn.click();
+ await getElementByCss(driver, "div#status");
+ successToastDiv = await getElementByCss(driver, "div[role=alert]");
+ await driver.wait(until.elementIsVisible(successToastDiv));
+ expect(await successToastDiv.getText()).toEqual("Login successful");
+ });
+});
diff --git a/browser-test/testData.json b/browser-test/testData.json
index ab8fb33a..b4bf89fd 100644
--- a/browser-test/testData.json
+++ b/browser-test/testData.json
@@ -4,6 +4,11 @@
"password": "testuser",
"organization": "default"
},
+ "expiredPasswordUser": {
+ "email": "expiredpassworduser@openwisp.org",
+ "password": "testuser",
+ "organization": "default"
+ },
"mobileVerificationTestUser": {
"phoneNumber": "+911234567890",
"password": "testuser",
diff --git a/client/components/password-change/__snapshots__/password-change.test.js.snap b/client/components/password-change/__snapshots__/password-change.test.js.snap
index 8b6dfe3e..21b6780a 100644
--- a/client/components/password-change/__snapshots__/password-change.test.js.snap
+++ b/client/components/password-change/__snapshots__/password-change.test.js.snap
@@ -1,5 +1,149 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
+exports[` rendering should not show 'cancel' button if password is expired 1`] = `
+
+`;
+
exports[` rendering should render correctly 1`] = `
-
-
- {t`CANCEL`}
-
-
+ {userData.password_expired !== true && (
+
+
+ {t`CANCEL`}
+
+
+ )}
diff --git a/client/components/password-change/password-change.test.js b/client/components/password-change/password-change.test.js
index ba870141..bf36d3a6 100644
--- a/client/components/password-change/password-change.test.js
+++ b/client/components/password-change/password-change.test.js
@@ -64,6 +64,14 @@ describe(" rendering", () => {
const wrapper = createShallow(props);
expect(wrapper).toMatchSnapshot();
});
+
+ it("should not show 'cancel' button if password is expired", async () => {
+ props = createTestProps();
+ props.userData.password_expired = true;
+ loadTranslation("en", "default");
+ const wrapper = createShallow(props);
+ expect(wrapper).toMatchSnapshot();
+ });
});
describe(" interactions", () => {
diff --git a/client/components/status/status.js b/client/components/status/status.js
index f8735f13..9c648296 100644
--- a/client/components/status/status.js
+++ b/client/components/status/status.js
@@ -67,6 +67,7 @@ export default class Status extends React.Component {
setTitle,
orgName,
language,
+ navigate,
} = this.props;
setTitle(t`STATUS_TITL`, orgName);
const {setLoading} = this.context;
@@ -114,7 +115,17 @@ export default class Status extends React.Component {
const {mustLogin, mustLogout, repeatLogin} = userData;
({userData} = this.props);
-
+ if (userData.password_expired === true) {
+ toast.warning(t`PASSWORD_EXPIRED`);
+ setUserData({
+ ...userData,
+ mustLogin,
+ mustLogout,
+ repeatLogin,
+ });
+ navigate(`/${orgSlug}/change-password`);
+ return;
+ }
const {
radius_user_token: password,
username,
diff --git a/client/components/status/status.test.js b/client/components/status/status.test.js
index d80ff02d..b43109ee 100644
--- a/client/components/status/status.test.js
+++ b/client/components/status/status.test.js
@@ -836,6 +836,48 @@ describe(" interactions", () => {
expect(wrapper.instance().state.hasMoreSessions).toEqual(false);
});
+ it("should not perform captive portal login if user password has expired", async () => {
+ validateToken.mockReturnValue(true);
+ // mock session fetching
+ jest.spyOn(Status.prototype, "getUserActiveRadiusSessions");
+ jest.spyOn(Status.prototype, "getUserPassedRadiusSessions");
+
+ props = createTestProps();
+ props.userData = {
+ ...responseData,
+ is_verified: false,
+ password_expired: true,
+ mustLogin: true,
+ };
+ const setLoading = jest.fn();
+ wrapper = shallow(, {
+ context: {setLoading},
+ });
+
+ // mock loginFormRef
+ const spyFn = jest.fn();
+ wrapper.instance().loginFormRef.current = {submit: spyFn};
+ await tick();
+
+ // ensure captive portal login is not performed
+ expect(spyFn.mock.calls.length).toBe(0);
+ expect(setLoading.mock.calls.length).toBe(1);
+
+ const mockRef = {submit: jest.fn()};
+ wrapper.instance().loginIframeRef.current = {};
+ wrapper.instance().loginFormRef.current = mockRef;
+
+ // ensure user is redirected to payment URL
+ expect(props.navigate).toHaveBeenCalledWith(
+ `/${props.orgSlug}/change-password`,
+ );
+ // ensure sessions are not fetched
+ expect(Status.prototype.getUserActiveRadiusSessions).not.toHaveBeenCalled();
+ expect(Status.prototype.getUserPassedRadiusSessions).not.toHaveBeenCalled();
+ // ensure loading overlay not removed
+ expect(setLoading.mock.calls.length).toBe(1);
+ });
+
it("should initiate bank_card verification", async () => {
validateToken.mockReturnValue(true);
// mock window.location.assign
diff --git a/client/utils/utils.test.js b/client/utils/utils.test.js
index 8b4396f4..6a1b9a7b 100644
--- a/client/utils/utils.test.js
+++ b/client/utils/utils.test.js
@@ -259,6 +259,39 @@ describe("Validate Token tests", () => {
expect(setUserData.mock.calls.length).toBe(0);
expect(logout.mock.calls.length).toBe(0);
});
+ it("should make api call if radius token is present but password_expired is true", async () => {
+ axios.mockImplementationOnce(() =>
+ Promise.resolve({
+ status: 200,
+ statusText: "OK",
+ data: {
+ response_code: "AUTH_TOKEN_VALIDATION_SUCCESSFUL",
+ radius_user_token: "o6AQLY0aQjD3yuihRKLknTn8krcQwuy2Av6MCsFB",
+ username: "tester@tester.com",
+ is_active: true,
+ is_verified: true,
+ phone_number: "+393660011222",
+ },
+ }),
+ );
+ const {orgSlug, cookies, setUserData, userData, logout, language} =
+ getArgs();
+ cookies.set(`${orgSlug}_auth_token`, "token");
+ userData.password_expired = true;
+ userData.radius_user_token = "token";
+ const result = await validateToken(
+ cookies,
+ orgSlug,
+ setUserData,
+ userData,
+ logout,
+ language,
+ );
+ expect(axios).toHaveBeenCalled();
+ expect(setUserData.mock.calls.length).toBe(1);
+ expect(result).toBe(true);
+ expect(logout.mock.calls.length).toBe(0);
+ });
it("should return false when internal server error", async () => {
const response = {
status: 500,
diff --git a/client/utils/validate-token.js b/client/utils/validate-token.js
index 5ff8d390..7c2a9296 100644
--- a/client/utils/validate-token.js
+++ b/client/utils/validate-token.js
@@ -24,7 +24,9 @@ const validateToken = async (
// or payment_url of user is undefined
if (
userData &&
- ((token && userData.radius_user_token === undefined) ||
+ ((token &&
+ (userData.radius_user_token === undefined ||
+ userData.password_expired === true)) ||
(userData.method === "bank_card" &&
userData.is_verified !== true &&
!userData.payment_url))
diff --git a/i18n/de.po b/i18n/de.po
index 81a35c44..c2b7e062 100644
--- a/i18n/de.po
+++ b/i18n/de.po
@@ -37,6 +37,10 @@ msgstr "Es ist ein Fehler aufgetreten!"
msgid "LOGOUT_SUCCESS"
msgstr "Logout war erfolgreich"
+#: client/components/status/status.js:119
+msgid "PASSWORD_EXPIRED"
+msgstr "Ihr Passwort ist abgelaufen. Bitte aktualisieren Sie es."
+
#: client/components/status/status.js:467
#: client/components/status/status.js:658
msgid "ACCT_ACTIVE"
diff --git a/i18n/en.po b/i18n/en.po
index 0c119b9e..32fb54c3 100644
--- a/i18n/en.po
+++ b/i18n/en.po
@@ -517,6 +517,10 @@ msgstr "Login successful"
msgid "LOGOUT_SUCCESS"
msgstr "Logout successful"
+#: client/components/status/status.js:119
+msgid "PASSWORD_EXPIRED"
+msgstr "Your password has expired, please update it."
+
#: client/components/password-change/password-change.js:101
#: client/components/password-change/password-change.js:109
#: client/components/password-change/password-change.test.js:129
diff --git a/i18n/fur.po b/i18n/fur.po
index 10e47b3c..2e4d6b9b 100644
--- a/i18n/fur.po
+++ b/i18n/fur.po
@@ -510,6 +510,10 @@ msgstr "Al è capitât un erôr!"
msgid "LOGOUT_SUCCESS"
msgstr "Logout fat cun sucès"
+#: client/components/status/status.js:119
+msgid "PASSWORD_EXPIRED"
+msgstr "La tô password al è scjadude, ti prei di aggiornâle."
+
#: client/components/password-change/password-change.js:61
#: client/components/password-change/password-change.test.js:115
#: client/components/password-change/password-change.test.js:120
diff --git a/i18n/it.po b/i18n/it.po
index e6ba31e7..f114a4b9 100644
--- a/i18n/it.po
+++ b/i18n/it.po
@@ -512,6 +512,10 @@ msgstr "Si è verificato un errore!"
msgid "LOGOUT_SUCCESS"
msgstr "Log out effettuato con successo"
+#: client/components/status/status.js:119
+msgid "PASSWORD_EXPIRED"
+msgstr "La tua password è scaduta, aggiornala."
+
#: client/components/password-change/password-change.js:61
#: client/components/password-change/password-change.test.js:115
#: client/components/password-change/password-change.test.js:120
diff --git a/i18n/ru.po b/i18n/ru.po
index 92bb7f87..dba0d5ca 100644
--- a/i18n/ru.po
+++ b/i18n/ru.po
@@ -508,6 +508,10 @@ msgstr "Была ошибка!"
msgid "LOGOUT_SUCCESS"
msgstr "Успешный выход"
+#: client/components/status/status.js:119
+msgid "PASSWORD_EXPIRED"
+msgstr "Срок действия вашего пароля истек, обновите его."
+
#: client/components/password-change/password-change.js:61
#: client/components/password-change/password-change.test.js:115
#: client/components/password-change/password-change.test.js:120
diff --git a/i18n/sl.po b/i18n/sl.po
index baf8fe0f..f9c3ff69 100644
--- a/i18n/sl.po
+++ b/i18n/sl.po
@@ -454,6 +454,10 @@ msgstr "Prijava je bila uspešna"
msgid "LOGOUT_SUCCESS"
msgstr "Odjava je bila uspešna"
+#: client/components/status/status.js:119
+msgid "PASSWORD_EXPIRED"
+msgstr "Vaše geslo je poteklo, posodobite ga."
+
#: client/components/password-change/password-change.js:87
#: client/components/password-change/password-change.js:93
#: client/components/password-change/password-change.test.js:129