diff --git a/docs/waf-login-refresh-accessToken.md b/docs/waf-login-refresh-accessToken.md new file mode 100644 index 00000000..5fbcf3c7 --- /dev/null +++ b/docs/waf-login-refresh-accessToken.md @@ -0,0 +1,192 @@ +# F5 BIG-IP WAF — Login / Refresh `accessToken` follow-up + +Date: 2026-10-28 +Related symptom: after login (or later on the dashboard), `localStorage.accessToken` becomes the string `"undefined"`, white blank screen, user cannot logout until local storage is cleared. Worse with **WAF blocking mode**; usually fine when blocking is off. + +## Goal + +`/api/login` and `/api/refresh-token` (and iAM Smart login APIs) must return **untouched JSON** from the backend in blocking mode, with a real JWT in `accessToken` / `refreshToken`. + +The WAF must **not**: + +- Replace the body with an HTML block / challenge page +- Strip or alter the `accessToken` field +- Rewrite long base64url JWT strings in JSON + +## App-side mitigation (already done) + +Frontend changes stop a permanent stuck white screen: + +- JWT payload decode uses base64url (not raw `atob` on `-` / `_`) +- Do not store a missing / `"undefined"` access token +- Force clear session and redirect to `/login` when token is unusable + +App mitigation does **not** make a WAF-damaged response succeed. WAF/LB still needs tuning so login/refresh return a real JWT. + +## API paths to protect (PNSPS) + +Base context path: `/api` (see backend `application.yml`). + +| Purpose | Method | Path (after host) | +|---------|--------|-------------------| +| Password login | POST | `/api/login` | +| Refresh token | POST | `/api/refresh-token` | +| Logout | POST | `/api/logout` | +| iAM Smart login | POST | `/api/smart/login` | +| iAM Smart direct login | POST | `/api/smart/directLogin` | + +Confirm exact URLs in browser **Network** tab if your reverse-proxy path differs. + +--- + +## F5 BIG-IP WAF — step by step + +Menu names vary by BIG-IP / ASM / Advanced WAF version. Use the closest equivalent. + +### 1. Confirm which policy sits in front of PNSPS + +1. Log in to the BIG-IP UI. +2. Go to **Local Traffic → Virtual Servers**. +3. Open the virtual server for `pnsps.gld.gov.hk` (or the VS that terminates TLS for the site). +4. Note the attached **Application Security Policy** (ASM) / security policy name. +5. Also check whether **Bot Defense**, **DoS**, or other profiles are attached — those can inject HTML challenges. + +### 2. Find what was blocked when `accessToken` became `"undefined"` + +1. Go to **Security → Event Logs → Application → Requests** + (or **Security → Application Security → Event Logs**). +2. Filter around the time of a failed login or blank dashboard. +3. Look for: + - `/api/login` + - `/api/refresh-token` + - `/api/smart/login` / `/api/smart/directLogin` if using iAM Smart +4. Open the event and record: + - **Support ID** + - **Violation** name (signature, illegal meta character, evasion, bot challenge, etc.) + - Whether the **response** was a block / challenge page instead of JSON + +That violation name is what you exception. + +### 3. Ensure API URLs exist in the policy + +1. Open the security policy → **URLs** (or **Policy → URLs**). +2. Ensure these URLs exist (exact path as in Network tab), for example: + - `/api/login` + - `/api/refresh-token` + - `/api/smart/login` + - `/api/smart/directLogin` +3. Method: **POST**. Prefer a **JSON** content profile if the policy uses content profiles. + +### 4. Disable response rewriting for these APIs + +In the policy, review anything that inspects or rewrites **responses**: + +1. **Data Guard** / **Response scrubbing** / **Information leakage** + - Disable for `/api/*`, or exclude the URLs above. +2. **Response signatures** / checks that modify the body + - Prefer alarm-only on responses for API URLs, or exclude them. +3. **Custom / attack signatures** that false-positive on JWT-looking JSON values + - JWTs contain `-`, `_`, `.` and long base64url strings. + - From the event log (step 2), **Learn / Allow / Exception** that signature for that URL only. + +### 5. Add a targeted exception (preferred once violation is known) + +From the blocked request in the event log: + +1. Open the request → **Accept / Learn** or **Create exception**. +2. Scope narrowly: + - **URL** = `/api/login` and/or `/api/refresh-token` (and smart login URLs if needed) + - Prefer **this signature + this URL**, not disabling the whole policy +3. Apply / publish the policy. + +### 6. Bot Defense / challenge pages + +If the log shows a bot challenge or CAPTCHA HTML page on login/refresh: + +1. Open **Security → Bot Defense** (or the policy Bot profile). +2. Whitelist / exception: + - `/api/login` + - `/api/refresh-token` + - smart login paths if needed +3. Those endpoints must return **`application/json`** from the app, not an HTML interstitial. + +### 7. Transparent vs Blocking (UAT validation only) + +1. On a **UAT** policy clone, briefly use **Transparent** (or transparent blocking). +2. Reproduce login / stay on dashboard through refresh. +3. If the problem disappears only in transparent mode, the blocking policy is still rewriting or blocking — use event logs to exception those violations, then return to **Blocking**. +4. Do **not** leave production in transparent as the long-term fix. + +### 8. Verify end-to-end + +1. Browser → log in → **Network** → **login** response: + - `Content-Type: application/json` + - Body contains long `accessToken` and `refreshToken` strings (three JWT segments each) +2. Wait for refresh near expiry (or trigger refresh) → same checks on **refresh-token**. +3. Console: + +```javascript +localStorage.getItem('accessToken') +``` + +Must **never** be the literal `"undefined"`. + +--- + +## Message for the F5 admin (copy/paste) + +> PNSPS SPA needs `/api/login` and `/api/refresh-token` (POST, JSON), and if used `/api/smart/login` and `/api/smart/directLogin`, to return the backend body unchanged in WAF **blocking** mode. The WAF must not replace them with an HTML block/challenge page or strip/alter `accessToken`. Please identify the violating signature/URL from ASM Event Logs and add a narrow exception; also disable response Data Guard / bot challenge on those API paths if they rewrite JSON. + +--- + +## App verification checklist (after frontend deploy) + +| Test | Expected | +|------|----------| +| Normal password login + logout | Dashboard OK; clean logout | +| Console: plant `accessToken` = `eyJhbGciOiJIUzUxMiJ9.Pj4-.sig` without `userData`, reload, wait 15s | No repeating `InvalidCharacterError`; redirect to `/login` | +| Console: plant `accessToken` = `"undefined"` with `userData`, reload | Within ~10s redirect to `/login`; session keys cleared | +| Login/refresh body missing `accessToken` (WAF case) | Stay on login / fail page or force logout — **not** permanent white screen | +| iAM Smart success | Still works with real JWT | + +### Console helpers + +Plant base64url payload that old `atob` rejected: + +```javascript +localStorage.removeItem('userData'); +localStorage.setItem('accessToken', 'eyJhbGciOiJIUzUxMiJ9.Pj4-.sig'); +location.reload(); +``` + +Plant stuck `"undefined"` session: + +```javascript +localStorage.setItem('userData', JSON.stringify({id:1,type:'IND',role:'primary'})); +localStorage.setItem('accessToken', 'undefined'); +location.reload(); +``` + +Cleanup: + +```javascript +localStorage.removeItem('accessToken'); +localStorage.removeItem('userData'); +location.reload(); +``` + +--- + +## Responsibility split + +| Layer | Role | +|-------|------| +| Frontend | Do not store bad tokens; force logout instead of stuck white screen | +| F5 WAF / LB | Do not damage login/refresh JSON so `accessToken` is present and valid | + +## Related frontend files + +- `src/components/RefreshTokenProvider.js` — base64url decode + force logout on bad token +- `src/auth/index.js` — `isAccessTokenUsable`, `forceLocalLogoutRedirect`, login/refresh guards +- `src/auth/jwt/jwtService.js` — same guard on JwtService 401 refresh +- Login forms / iAM Smart callbacks — skip success navigate when token unusable diff --git a/src/auth/index.js b/src/auth/index.js index 7ad7c48d..30c94812 100644 --- a/src/auth/index.js +++ b/src/auth/index.js @@ -4,12 +4,27 @@ import {isUserLoggedIn} from "../utils/Utils"; import jwtApplicationConfig from "auth/jwtApplicationConfig"; import jwt_decode from "jwt-decode"; import {apiPath} from "./utils"; +import { + refreshIntervalName, + predictProductionQty, + predictUsageCount, + windowCount, + isAccessTokenUsable, + forceLocalLogoutRedirect, +} from "./tokenSession"; + +// Re-export for existing callers +export { + refreshIntervalName, + predictProductionQty, + predictUsageCount, + windowCount, + isAccessTokenUsable, + clearLocalSession, + forceLocalLogoutRedirect, +} from "./tokenSession"; //import axios from "axios"; -export const refreshIntervalName = 'refreshInterval' -export const predictProductionQty = 'predictProductionQty' -export const predictUsageCount = 'predictUsageCount' -export const windowCount = 'windowCount' import {useNavigate} from "react-router-dom"; import {useDispatch} from "react-redux"; import { LOGOUT, REFRESH_TOKEN } from 'utils/ApiPathConst'; @@ -33,6 +48,9 @@ export const clearExpiredSessionAlert = () => { // ** Handle User Login export const handleLogin = data => { return dispatch => { + if (!isAccessTokenUsable(data.accessToken)) { + return; + } dispatch({ type: 'LOGIN', @@ -200,10 +218,19 @@ export const SetupAxiosInterceptors = () => { if (response.status === 200) { const newAccessToken = response.data.accessToken; const newRefreshToken = response.data.refreshToken; - localStorage.setItem('accessToken', newAccessToken); - localStorage.setItem('refreshToken', newRefreshToken); - isRefreshToken = false; - window.location.reload(); + if ( + isAccessTokenUsable(newAccessToken) && + typeof newRefreshToken === 'string' && + newRefreshToken + ) { + localStorage.setItem('accessToken', newAccessToken); + localStorage.setItem('refreshToken', newRefreshToken); + isRefreshToken = false; + window.location.reload(); + } else { + isRefreshToken = false; + forceLocalLogoutRedirect(); + } } }) .catch((refreshError) => { @@ -273,10 +300,18 @@ export const handleRefreshTokenFunction = () => { if (response.status === 200) { const newAccessToken = response.data.accessToken; const newRefreshToken = response.data.refreshToken; - localStorage.setItem('accessToken', newAccessToken); - localStorage.setItem('refreshToken', newRefreshToken); - // token = newAccessToken; - isRefresh = false; + if ( + isAccessTokenUsable(newAccessToken) && + typeof newRefreshToken === 'string' && + newRefreshToken + ) { + localStorage.setItem('accessToken', newAccessToken); + localStorage.setItem('refreshToken', newRefreshToken); + isRefresh = false; + } else { + isRefresh = false; + forceLocalLogoutRedirect(); + } } else { // token = null; isRefresh = false; diff --git a/src/auth/jwt/jwtService.js b/src/auth/jwt/jwtService.js index 430717a0..b25592d2 100644 --- a/src/auth/jwt/jwtService.js +++ b/src/auth/jwt/jwtService.js @@ -1,5 +1,6 @@ import axios from 'axios' import jwtDefaultConfig from './jwtDefaultConfig' +import { isAccessTokenUsable, forceLocalLogoutRedirect } from '../tokenSession' export default class JwtService { // ** jwtConfig <= Will be used by this service @@ -57,11 +58,21 @@ export default class JwtService { .then(r => { this.isAlreadyFetchingAccessToken = false - // ** Update accessToken in localStorage - this.setToken(r.data.accessToken) - this.setRefreshToken(r.data.refreshToken) - - this.onAccessTokenFetched(r.data.accessToken) + const newAccessToken = r.data.accessToken + const newRefreshToken = r.data.refreshToken + if ( + isAccessTokenUsable(newAccessToken) && + typeof newRefreshToken === 'string' && + newRefreshToken + ) { + // ** Update accessToken in localStorage + this.setToken(newAccessToken) + this.setRefreshToken(newRefreshToken) + this.onAccessTokenFetched(newAccessToken) + } else { + this.onAccessTokenFetchFailed(new Error('Invalid access token from refresh')) + forceLocalLogoutRedirect() + } }) .catch(refreshError => { this.isAlreadyFetchingAccessToken = false diff --git a/src/auth/tokenSession.js b/src/auth/tokenSession.js new file mode 100644 index 00000000..1d983ece --- /dev/null +++ b/src/auth/tokenSession.js @@ -0,0 +1,33 @@ +/** Session token helpers — keep free of imports from auth/index or jwtService (avoids circular init). */ + +export const refreshIntervalName = 'refreshInterval' +export const predictProductionQty = 'predictProductionQty' +export const predictUsageCount = 'predictUsageCount' +export const windowCount = 'windowCount' + +/** JWT-shaped access token (blocks missing/`undefined` from WAF-damaged responses). */ +export const isAccessTokenUsable = (t) => + typeof t === 'string' && + t !== 'undefined' && + t !== 'null' && + t.split('.').length === 3; + +/** Clear session keys without calling logout API (bad/missing token cases). */ +export const clearLocalSession = () => { + localStorage.removeItem('userData') + localStorage.removeItem('accessToken') + localStorage.removeItem('refreshToken') + localStorage.removeItem('axiosToken') + localStorage.removeItem('webtoken') + localStorage.removeItem('transactionid') + localStorage.removeItem('searchCriteria') + localStorage.removeItem(refreshIntervalName) + localStorage.removeItem(windowCount) + localStorage.removeItem(predictProductionQty) + localStorage.removeItem(predictUsageCount) +}; + +export const forceLocalLogoutRedirect = () => { + clearLocalSession(); + window.location.assign('/login'); +}; diff --git a/src/components/RefreshTokenProvider.js b/src/components/RefreshTokenProvider.js index 148eb11b..865e9031 100644 --- a/src/components/RefreshTokenProvider.js +++ b/src/components/RefreshTokenProvider.js @@ -5,6 +5,7 @@ import axios from 'axios'; import { isUserLoggedIn, } from "utils/Utils"; +import { isAccessTokenUsable, forceLocalLogoutRedirect } from 'auth/tokenSession'; const RefreshTokenContext = createContext(); const RefreshTokenProvider = ({ children }) => { @@ -24,10 +25,20 @@ const RefreshTokenProvider = ({ children }) => { if (response.status === 200) { const newAccessToken = response.data.accessToken; const newRefreshToken = response.data.refreshToken; - localStorage.setItem('accessToken', newAccessToken); - localStorage.setItem('refreshToken', newRefreshToken); - token.current = newAccessToken; - isRefresh.current = false; + if ( + isAccessTokenUsable(newAccessToken) && + typeof newRefreshToken === 'string' && + newRefreshToken + ) { + localStorage.setItem('accessToken', newAccessToken); + localStorage.setItem('refreshToken', newRefreshToken); + token.current = newAccessToken; + isRefresh.current = false; + } else { + token.current = null; + isRefresh.current = false; + forceLocalLogoutRedirect(); + } } else { token.current = null; isRefresh.current = false; @@ -48,6 +59,11 @@ const RefreshTokenProvider = ({ children }) => { const checkTokenExpiry = useCallback(() => { // Check if token is present and its expiry time if (token.current) { + if (!isAccessTokenUsable(token.current)) { + token.current = null; + forceLocalLogoutRedirect(); + return; + } let tokenExp; try { // JWT payload is base64url; atob needs standard base64 @@ -58,6 +74,7 @@ const RefreshTokenProvider = ({ children }) => { tokenExp = JSON.parse(atob(payload)).exp; } catch (e) { token.current = null; + forceLocalLogoutRedirect(); return; } const currentTime = Math.floor(Date.now() / 1000); @@ -75,7 +92,7 @@ const RefreshTokenProvider = ({ children }) => { } } } - }, [token]); + }, [token, handleRefreshToken]); // Start the timer on component mount useEffect(() => { diff --git a/src/pages/authentication/auth-forms/AuthLogin.js b/src/pages/authentication/auth-forms/AuthLogin.js index a59db83b..826e62ea 100644 --- a/src/pages/authentication/auth-forms/AuthLogin.js +++ b/src/pages/authentication/auth-forms/AuthLogin.js @@ -28,7 +28,7 @@ import {POST_LOGIN} from "utils/ApiPathConst"; import { EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons'; import axios from "axios"; import {useDispatch} from "react-redux"; -import {handleLogin} from "auth/index"; +import {handleLogin, isAccessTokenUsable} from "auth/index"; import {FormattedMessage} from "react-intl"; // ============================|| FIREBASE - LOGIN ||============================ // @@ -90,6 +90,11 @@ const AuthLogin = () => { // ability.update(abilities) const data = {...userData, accessToken: response.data.accessToken, refreshToken: response.data.refreshToken} + if (!isAccessTokenUsable(response.data.accessToken)) { + loginInFlightRef.current = false; + setLoginRequestPending(false); + return; + } dispatch(handleLogin(data)) navigate('/dashboard'); //history.push(getHomeRouteForLoggedInUser("user")) diff --git a/src/pages/authentication/auth-forms/AuthLoginCustom.js b/src/pages/authentication/auth-forms/AuthLoginCustom.js index 8d301f9e..c9ca7c2c 100644 --- a/src/pages/authentication/auth-forms/AuthLoginCustom.js +++ b/src/pages/authentication/auth-forms/AuthLoginCustom.js @@ -40,7 +40,7 @@ import { EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons'; // import axios from "axios"; import { useDispatch } from "react-redux"; -import { handleLogin, clearExpiredSessionAlert } from "auth/index"; +import { handleLogin, clearExpiredSessionAlert, isAccessTokenUsable } from "auth/index"; import useJwt from "auth/jwt/useJwt"; import { FormattedMessage, useIntl } from "react-intl"; import { SysContext } from "components/SysSettingProvider" @@ -112,6 +112,13 @@ const AuthLoginCustom = () => { const data = { ...userData, accessToken: response.data.accessToken, refreshToken: response.data.refreshToken } // setSuccess(true) // console.log(response.data); + if (!isAccessTokenUsable(response.data.accessToken)) { + loginInFlightRef.current = false; + setOnLogin(false) + setErrorMassage("CONNECTION_ERROR") + setOpen(true) + return; + } if (response.data.type === "GLD") { // setLocale("en"); localStorage.setItem('locale', 'en'); diff --git a/src/pages/iAmSmart/AuthCallback/index.js b/src/pages/iAmSmart/AuthCallback/index.js index 57f8b16d..3cbf3922 100644 --- a/src/pages/iAmSmart/AuthCallback/index.js +++ b/src/pages/iAmSmart/AuthCallback/index.js @@ -4,7 +4,7 @@ import * as HttpUtils from "utils/HttpUtils"; import * as UrlUtils from "utils/ApiPathConst"; import { useNavigate } from "react-router-dom"; import { useDispatch } from "react-redux"; -import { handleLogoutFunction, handleLogin } from 'auth/index'; +import { handleLogoutFunction, handleLogin, isAccessTokenUsable } from 'auth/index'; import Loadable from 'components/Loadable'; const LoadingComponent = Loadable(React.lazy(() => import('pages/extra-pages/LoadingComponent'))); @@ -69,6 +69,10 @@ const Index = () => { // localStorage.setItem('locale','en'); // } // } + if (!isAccessTokenUsable(responseData.accessToken)) { + window.location.assign("/iamsmart/loginFail"); + return; + } dispatch(handleLogin(data)) navigate('/iamsmart/loginSucess', { state: { diff --git a/src/pages/iAmSmart/DirectLoginCallback/index.js b/src/pages/iAmSmart/DirectLoginCallback/index.js index 2f599185..9db034cc 100644 --- a/src/pages/iAmSmart/DirectLoginCallback/index.js +++ b/src/pages/iAmSmart/DirectLoginCallback/index.js @@ -4,7 +4,7 @@ import * as HttpUtils from "utils/HttpUtils"; import * as UrlUtils from "utils/ApiPathConst"; import { useNavigate } from "react-router-dom"; import { useDispatch } from "react-redux"; -import { handleLogoutFunction, handleLogin } from 'auth/index'; +import { handleLogoutFunction, handleLogin, isAccessTokenUsable } from 'auth/index'; import Loadable from 'components/Loadable'; const LoadingComponent = Loadable(React.lazy(() => import('pages/extra-pages/LoadingComponent'))); @@ -51,6 +51,10 @@ const Index = () => { //avatar: require('src/assets/images/users/avatar-3.png').default, } const data = { ...userData, accessToken: responseData.accessToken, refreshToken: responseData.refreshToken } + if (!isAccessTokenUsable(responseData.accessToken)) { + window.location.assign("/iamsmart/loginFail"); + return; + } dispatch(handleLogin(data)) navigate('/dashboard'); },