Selaa lähdekoodia

fix undefined accessToken, with WAF guide

master
vanhempi
commit
74cc5f5a8e
9 muutettua tiedostoa jossa 334 lisäystä ja 26 poistoa
  1. +192
    -0
      docs/waf-login-refresh-accessToken.md
  2. +47
    -12
      src/auth/index.js
  3. +16
    -5
      src/auth/jwt/jwtService.js
  4. +33
    -0
      src/auth/tokenSession.js
  5. +22
    -5
      src/components/RefreshTokenProvider.js
  6. +6
    -1
      src/pages/authentication/auth-forms/AuthLogin.js
  7. +8
    -1
      src/pages/authentication/auth-forms/AuthLoginCustom.js
  8. +5
    -1
      src/pages/iAmSmart/AuthCallback/index.js
  9. +5
    -1
      src/pages/iAmSmart/DirectLoginCallback/index.js

+ 192
- 0
docs/waf-login-refresh-accessToken.md Näytä tiedosto

@@ -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

+ 47
- 12
src/auth/index.js Näytä tiedosto

@@ -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;


+ 16
- 5
src/auth/jwt/jwtService.js Näytä tiedosto

@@ -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


+ 33
- 0
src/auth/tokenSession.js Näytä tiedosto

@@ -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');
};

+ 22
- 5
src/components/RefreshTokenProvider.js Näytä tiedosto

@@ -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(() => {


+ 6
- 1
src/pages/authentication/auth-forms/AuthLogin.js Näytä tiedosto

@@ -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"))


+ 8
- 1
src/pages/authentication/auth-forms/AuthLoginCustom.js Näytä tiedosto

@@ -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');


+ 5
- 1
src/pages/iAmSmart/AuthCallback/index.js Näytä tiedosto

@@ -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: {


+ 5
- 1
src/pages/iAmSmart/DirectLoginCallback/index.js Näytä tiedosto

@@ -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');
},


Ladataan…
Peruuta
Tallenna