From 4507596456b692317dd972974fe69830ea72bc3d Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 15 Nov 2023 19:25:03 +0530 Subject: [PATCH] [feature] Added support for password expiration feature #713 Closes #713 --- .github/workflows/ci.yml | 4 +- browser-test/initialize_data.py | 20 ++- browser-test/local_settings.py | 2 + browser-test/password-expired.test.js | 91 +++++++++++ browser-test/testData.json | 5 + .../password-change.test.js.snap | 144 ++++++++++++++++++ .../password-change/password-change.js | 28 ++-- .../password-change/password-change.test.js | 8 + client/components/status/status.js | 13 +- client/components/status/status.test.js | 42 +++++ client/utils/utils.test.js | 33 ++++ client/utils/validate-token.js | 4 +- i18n/de.po | 4 + i18n/en.po | 4 + i18n/fur.po | 4 + i18n/it.po | 4 + i18n/ru.po | 4 + i18n/sl.po | 4 + 18 files changed, 399 insertions(+), 19 deletions(-) create mode 100644 browser-test/local_settings.py create mode 100644 browser-test/password-expired.test.js 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`] = ` +
+
+
+
+

+ Change your password +

+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+
+
+ +
+
+`; + 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