fixed auth tokens persistance
This commit is contained in:
parent
9b9948f2ad
commit
53c683023e
@ -264,9 +264,11 @@ const authLimiter = createRateLimiter({
|
||||
Nginx and DNS-only Apache configurations replace supplied chains with their
|
||||
socket client address.
|
||||
|
||||
Bearer tokens are stored in `sessionStorage` only. Older `localStorage` token
|
||||
and user entries are removed during login and logout so credentials do not
|
||||
survive closing the browser.
|
||||
The login form's **Remember** option controls bearer-token storage. It is
|
||||
checked by default and stores the token in `localStorage`, which keeps private
|
||||
presentations available across tabs and browser restarts. When unchecked, the
|
||||
token stays in `sessionStorage` and lasts only for the current tab. Login clears
|
||||
the inactive storage location, and logout clears both.
|
||||
|
||||
## User Model
|
||||
|
||||
@ -389,8 +391,8 @@ builder.addCase(loginUser.fulfilled, (state, action) => {
|
||||
const token = action.payload;
|
||||
const user = decodeAuthToken(token);
|
||||
|
||||
// Session-scoped storage also removes legacy localStorage credentials
|
||||
storeAuthSession(token, user);
|
||||
// Remembered sessions use localStorage; other sessions stay in this tab.
|
||||
storeAuthSession(token, user, action.meta.arg.remember);
|
||||
|
||||
// Set default header
|
||||
axios.defaults.headers.common['Authorization'] = 'Bearer ' + token;
|
||||
@ -568,7 +570,7 @@ const isPresignedS3Url = (url: string): boolean => {
|
||||
| Expiration | 6 hours |
|
||||
| Secret | Environment variable `SECRET_KEY` |
|
||||
| Transmission | Bearer token in Authorization header |
|
||||
| Storage | `sessionStorage` through `lib/authStorage.ts` |
|
||||
| Storage | `localStorage` when Remember is checked; otherwise `sessionStorage` |
|
||||
|
||||
### Verification Tokens
|
||||
|
||||
@ -770,6 +772,6 @@ res.status(429).send({
|
||||
- Ensure OAuth scopes are properly configured
|
||||
|
||||
**Token not persisting after refresh:**
|
||||
- Check `sessionStorage`; tokens intentionally do not survive closing the browser
|
||||
- Check `localStorage` for remembered login or `sessionStorage` for tab-only login
|
||||
- Verify axios interceptors are properly attached
|
||||
- Check for errors in browser console
|
||||
|
||||
@ -677,7 +677,7 @@ All entities include:
|
||||
▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Store Token │<────│ Return JWT │<────│ Generate JWT │
|
||||
│ (sessionStorage)│ │ │ │ (6h expiry) │
|
||||
│ (Remember/local) │ │ │ │ (6h expiry) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
|
||||
@ -22,10 +22,11 @@ belong to that presentation.
|
||||
|
||||
Current application risk is **moderate**. `npm audit --omit=dev` reports no
|
||||
known production dependency advisories. Accepted operational risks include
|
||||
credential delivery through the tracked backend `.env` file and Cloudflare's
|
||||
public wildcard CORS response headers. Bearer tokens remain browser-readable
|
||||
during an active tab session, but are no longer kept across browser sessions.
|
||||
The new response policy further reduces that exposure.
|
||||
credential delivery through the tracked backend `.env` file, Cloudflare's
|
||||
public wildcard CORS response headers, and browser-readable bearer tokens.
|
||||
The login form remembers tokens by default so internal users can open private
|
||||
presentations across tabs and browser restarts. CSP reduces the exposure but
|
||||
does not make `localStorage` inaccessible to same-origin scripts.
|
||||
|
||||
## Findings and Current Status
|
||||
|
||||
@ -35,8 +36,8 @@ The new response policy further reduces that exposure.
|
||||
| Public presign accepted arbitrary storage keys | Non-staff calls require production runtime context, presentation access, and project-owned asset references | No context `401`; private anonymous context `403` |
|
||||
| Forwarded IP spoofing bypassed rate limits | Express now trusts only the loopback proxy hop, so attacker-controlled addresses on the left of the tunnel's forwarded chain are ignored | Live old build gave separate buckets (`9`, `9`); local fixed build gave one bucket ending in `429`; public retest required after deployment |
|
||||
| Reflected credentialed CORS | The backend now uses a configured UI allowlist without credentials, but Cloudflare's public wildcard CORS headers will remain by owner decision | Direct backend fix verified locally; live public responses still return wildcard CORS with credentials |
|
||||
| Frontend lacked browser security headers | Added CSP, referrer policy, MIME sniffing protection, production HSTS, and removed the Next.js identifying header; development HTTP sources are scheme-based and iframe providers use one shared frontend allowlist | Development headers verified; production excludes `http:` and includes HSTS |
|
||||
| Bearer tokens persisted in local storage | Authentication is session-storage only; old local-storage credentials are removed on application startup, login, and logout | Static scan shows no remaining token reads or writes outside the storage helper |
|
||||
| Frontend lacked browser security headers | Added CSP, referrer policy, MIME sniffing protection, production HSTS, and removed the Next.js identifying header; development HTTP sources are scheme-based, iframe providers use one shared frontend allowlist, and Cloudflare Web Analytics is allowed from its fixed script origin | Development headers verified; production excludes `http:` and includes HSTS |
|
||||
| Bearer tokens persisted in local storage | Accepted for internal-use convenience when Remember is checked; unchecked login remains tab-scoped, and logout clears both stores | Browser tests cover remembered and tab-only login |
|
||||
| Static JWT and seed-password fallbacks | JWT secret is required and at least 32 characters; seed passwords are required only when seed users must be created | Backend starts with the VM-provided secret; known fallback administrator login remains inactive |
|
||||
| Health endpoint exposed environment and uptime | Public health response now contains only status, timestamp, and database state | Response verified locally |
|
||||
| Known production dependency advisories | Sharp is overridden to `0.35.3`; Express's nested body-parser is overridden to `1.20.6` | Both production audits report zero vulnerabilities |
|
||||
@ -72,11 +73,22 @@ mainly unauthenticated public API data, which other websites can call and read.
|
||||
Revisit this decision before switching to cookie authentication, exposing
|
||||
sensitive anonymous endpoints, or allowing broader external platform use.
|
||||
|
||||
## Accepted Risk: Remembered Browser Tokens
|
||||
|
||||
The login form remembers bearer tokens in `localStorage` by default. This lets
|
||||
internal users open private presentations in new tabs without signing in each
|
||||
time. Unchecking Remember uses `sessionStorage` instead, and logout clears both
|
||||
locations.
|
||||
|
||||
Any script running on the application origin can read these tokens. Keep the
|
||||
CSP script allowlist narrow and revisit HttpOnly cookies if the platform gains
|
||||
more external users or stores more sensitive data.
|
||||
|
||||
## Choices Kept Deliberately Simple
|
||||
|
||||
- Authentication remains bearer-token based; migration to HttpOnly cookies and
|
||||
CSRF tokens was not introduced. Session-only storage plus CSP provides a
|
||||
proportional improvement without rewriting authentication.
|
||||
CSRF tokens was not introduced. Remembered login is an accepted convenience
|
||||
tradeoff for the current internal-use deployment.
|
||||
- Rate limiting remains in memory because the deployment is a single backend
|
||||
process. Redis is not required for the current topology.
|
||||
- Swagger remains public, but it now receives Helmet security headers. It does
|
||||
@ -90,7 +102,7 @@ sensitive anonymous endpoints, or allowing broader external platform use.
|
||||
- Backend strict typecheck and lint: passed
|
||||
- Frontend strict typecheck and lint: passed
|
||||
- Backend tests: 82 unit, 14 database integration, and 3 HTTP E2E tests passed
|
||||
- Frontend tests: 316 unit and 14 Playwright browser tests passed
|
||||
- Frontend tests: 316 unit and 15 Playwright browser tests passed
|
||||
- Frontend production build: passed
|
||||
- Backend and frontend `npm audit --omit=dev`: zero vulnerabilities
|
||||
- Public-role database hardening audit: passed
|
||||
|
||||
@ -1156,7 +1156,9 @@ const nextConfig = {
|
||||
The CSP permits generic `http:` image, media, API, and WebSocket sources only
|
||||
in development, so local ports do not need to be duplicated in the header
|
||||
configuration. Production remains restricted to same-origin and HTTPS/WSS.
|
||||
Trusted iframe providers are defined once in
|
||||
Cloudflare Web Analytics may load scripts only from
|
||||
`static.cloudflareinsights.com`; its beacon uses the existing HTTPS connection
|
||||
policy. Trusted iframe providers are defined once in
|
||||
`src/config/embedDomains.json`; both CSP `frame-src` generation and frontend
|
||||
embed URL validation consume that list.
|
||||
|
||||
|
||||
@ -940,7 +940,7 @@ Redux is for client/app state. Use Redux slices for:
|
||||
|
||||
| State Type | Example | Why Redux |
|
||||
|------------|---------|-----------|
|
||||
| **Authentication** | Current user, JWT token | App-wide; credentials use sessionStorage |
|
||||
| **Authentication** | Current user, JWT token | App-wide; Remember uses localStorage, otherwise sessionStorage |
|
||||
| **UI Preferences** | Dark mode, theme settings | Persisted, affects entire app |
|
||||
| **Layout/App UI** | Sidebar, theme, app preferences | Shared client state |
|
||||
| **Constructor UI State** | Selected elements, canvas state | Shared builder interactions |
|
||||
|
||||
@ -16,15 +16,12 @@ const embedFrameSources = allowedEmbedDomains.flatMap((domain) => [
|
||||
]);
|
||||
const contentSecurityPolicy = [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'unsafe-inline'${isDevelopment ? " 'unsafe-eval'" : ''}`,
|
||||
`script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com${isDevelopment ? " 'unsafe-eval'" : ''}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
`img-src 'self' data: blob: https:${developmentHttpSources}`,
|
||||
`media-src 'self' blob: https:${developmentHttpSources}`,
|
||||
`connect-src 'self' https: wss:${isDevelopment ? ' http: ws:' : ''}`,
|
||||
[
|
||||
"frame-src 'self'",
|
||||
...embedFrameSources,
|
||||
].join(' '),
|
||||
["frame-src 'self'", ...embedFrameSources].join(' '),
|
||||
"font-src 'self' data:",
|
||||
"worker-src 'self' blob:",
|
||||
"object-src 'none'",
|
||||
|
||||
@ -6,26 +6,28 @@ const canUseBrowserStorage = (): boolean => typeof window !== 'undefined';
|
||||
export const getStoredAuthToken = (): string | null => {
|
||||
if (!canUseBrowserStorage()) return null;
|
||||
|
||||
// Clear credentials left by older builds as soon as the application loads.
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
return sessionStorage.getItem(TOKEN_KEY);
|
||||
return sessionStorage.getItem(TOKEN_KEY) ?? localStorage.getItem(TOKEN_KEY);
|
||||
};
|
||||
|
||||
export const storeAuthSession = (token: string, user?: unknown): void => {
|
||||
export const storeAuthSession = (
|
||||
token: string,
|
||||
user: unknown,
|
||||
remember: boolean,
|
||||
): void => {
|
||||
if (!canUseBrowserStorage()) return;
|
||||
|
||||
sessionStorage.setItem(TOKEN_KEY, token);
|
||||
const activeStorage = remember ? localStorage : sessionStorage;
|
||||
const inactiveStorage = remember ? sessionStorage : localStorage;
|
||||
|
||||
activeStorage.setItem(TOKEN_KEY, token);
|
||||
if (user) {
|
||||
sessionStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
activeStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
} else {
|
||||
sessionStorage.removeItem(USER_KEY);
|
||||
activeStorage.removeItem(USER_KEY);
|
||||
}
|
||||
|
||||
// Remove tokens written by older builds. Authentication is intentionally
|
||||
// session-scoped so closing the browser clears bearer credentials.
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
inactiveStorage.removeItem(TOKEN_KEY);
|
||||
inactiveStorage.removeItem(USER_KEY);
|
||||
};
|
||||
|
||||
export const clearAuthSession = (): void => {
|
||||
|
||||
@ -82,8 +82,7 @@ export default function Login() {
|
||||
};
|
||||
|
||||
const handleSubmit = async (value: typeof initialValues) => {
|
||||
const { remember, ...rest } = value;
|
||||
await dispatch(loginUser(rest));
|
||||
await dispatch(loginUser(value));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -16,12 +16,19 @@ const initialState: AuthState = {
|
||||
},
|
||||
};
|
||||
|
||||
interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
remember: boolean;
|
||||
}
|
||||
|
||||
export const resetAction = createAction('auth/passwordReset/reset');
|
||||
|
||||
export const loginUser = createAsyncThunk(
|
||||
'auth/loginUser',
|
||||
async (creds: Record<string, string>, { rejectWithValue }) => {
|
||||
async (credentials: LoginCredentials, { rejectWithValue }) => {
|
||||
try {
|
||||
const { remember: _remember, ...creds } = credentials;
|
||||
const response = await axios.post('auth/signin/local', creds);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
@ -81,7 +88,7 @@ export const authSlice = createSlice({
|
||||
state.errorMessage = '';
|
||||
state.isFetching = false;
|
||||
state.token = token;
|
||||
storeAuthSession(token, user);
|
||||
storeAuthSession(token, user, action.meta.arg.remember);
|
||||
axios.defaults.headers.common['Authorization'] = 'Bearer ' + token;
|
||||
});
|
||||
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { collectConsoleFailures, mockFrontendApi, testUser } from './fixtures';
|
||||
import {
|
||||
collectConsoleFailures,
|
||||
mockFrontendApi,
|
||||
testToken,
|
||||
testUser,
|
||||
} from './fixtures';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockFrontendApi(page);
|
||||
@ -32,5 +37,33 @@ test('local login reaches authenticated dashboard shell', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('link', { name: /Projects/ }).first(),
|
||||
).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => ({
|
||||
local: window.localStorage.getItem('token'),
|
||||
session: window.sessionStorage.getItem('token'),
|
||||
})),
|
||||
)
|
||||
.toEqual({ local: testToken, session: null });
|
||||
consoleFailures.assertClean();
|
||||
});
|
||||
|
||||
test('login without Remember keeps credentials in the current tab', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/login');
|
||||
await page.locator('input[name="email"]').fill(testUser.email);
|
||||
await page.locator('input[name="password"]').fill('not-used-with-api-mock');
|
||||
await page.locator('input[name="remember"]').uncheck({ force: true });
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => ({
|
||||
local: window.localStorage.getItem('token'),
|
||||
session: window.sessionStorage.getItem('token'),
|
||||
})),
|
||||
)
|
||||
.toEqual({ local: null, session: testToken });
|
||||
});
|
||||
|
||||
@ -433,9 +433,7 @@ export async function authenticate(page: Page, user = testUser) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
window.localStorage.setItem('token', token);
|
||||
window.sessionStorage.setItem('token', token);
|
||||
window.localStorage.setItem('user', JSON.stringify(user));
|
||||
window.sessionStorage.setItem('user', JSON.stringify(user));
|
||||
},
|
||||
{ token: testToken, user },
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user