Compare commits

..
Author SHA1 Message Date
Anthony Stirling 24cabb7070 email signup 2025-08-18 10:22:21 +01:00
Anthony Stirling a07f2cbe05 testing 2025-08-15 16:32:11 +01:00
Anthony Stirling 03a311e78d add linkedin 2025-08-15 14:53:49 +01:00
Anthony Stirling 2de89fb173 added other auths for fun 2025-08-15 14:40:48 +01:00
Anthony Stirling db815802f7 loginTest 2025-08-15 12:33:49 +01:00
531 changed files with 15221 additions and 45028 deletions
+2 -7
View File
@@ -10,13 +10,8 @@
"Bash(npm test)",
"Bash(npm test:*)",
"Bash(ls:*)",
"Bash(npx tsc:*)",
"Bash(node:*)",
"Bash(npm run dev:*)",
"Bash(sed:*)",
"Bash(npm run typecheck:*)"
"Bash(npx tsc:*)"
],
"deny": [],
"defaultMode": "acceptEdits"
"deny": []
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ indent_size = 2
insert_final_newline = false
trim_trailing_whitespace = false
[{*.js,*.jsx,*.mjs,*.ts,*.tsx}]
[{*.js,*.jsx,*.ts,*.tsx}]
indent_size = 2
[*.css]
+1 -5
View File
@@ -46,9 +46,6 @@ labels:
- label: 'API'
title: '.*openapi.*|.*swagger.*|.*api.*'
- label: 'v2'
base-branch: 'V2'
- label: 'Translation'
files:
- 'app/core/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}.properties'
@@ -65,7 +62,6 @@ labels:
- 'app/core/src/main/java/stirling/software/SPDF/controller/web/.*'
- 'app/core/src/main/java/stirling/software/SPDF/UI/.*'
- 'app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/.*'
- 'frontend/**'
- label: 'Java'
files:
@@ -124,7 +120,6 @@ labels:
- 'scripts/installFonts.sh'
- 'test.sh'
- 'test2.sh'
- 'docker/**'
- label: 'Devtools'
files:
@@ -136,6 +131,7 @@ labels:
- '.github/workflows/pre_commit.yml'
- 'devGuide/.*'
- 'devTools/.*'
- 'devTools/.*'
- label: 'Test'
files:
-1
View File
@@ -83,7 +83,6 @@
color: "DEDEDE"
- name: "v2"
color: "FFFF00"
description: "Issues or pull requests related to the v2 branch"
- name: "wontfix"
description: "This will not be worked on"
color: "FFFFFF"
@@ -47,7 +47,7 @@ jobs:
egress-policy: audit
- name: Checkout PR
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
@@ -158,7 +158,7 @@ jobs:
egress-policy: audit
- name: Checkout PR
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
@@ -170,7 +170,7 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Checkout PR
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: ${{ needs.check-comment.outputs.pr_repository }}
ref: ${{ needs.check-comment.outputs.pr_ref }}
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
egress-policy: audit
- name: Checkout PR
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
+2 -2
View File
@@ -23,7 +23,7 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
@@ -87,7 +87,7 @@ jobs:
- name: AI PR Title Analysis
if: steps.actor.outputs.is_repo_dev == 'true'
id: ai-title-analysis
uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1.2.8
uses: actions/ai-inference@0cbed4a10641c75090de5968e66d70eb4660f751 # v1.2.7
with:
model: openai/gpt-4o
system-prompt-file: ".github/config/system-prompt.txt"
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup GitHub App Bot
id: setup-bot
+12 -19
View File
@@ -31,7 +31,8 @@ jobs:
project: ${{ steps.changes.outputs.project }}
openapi: ${{ steps.changes.outputs.openapi }}
steps:
- uses: actions/checkout@v4.3.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check for file changes
uses: dorny/paths-filter@v3.0.2
@@ -55,15 +56,14 @@ jobs:
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@v4.3.0
uses: actions/checkout@v4.2.2
- name: Set up JDK ${{ matrix.jdk-version }}
uses: actions/setup-java@v4.7.1
with:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4.4.2
uses: gradle/actions/setup-gradle@v4.4.1
with:
gradle-version: 8.14
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
@@ -106,24 +106,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@v2.13.0
uses: step-security/harden-runner@v2.12.2
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@v4.3.0
- uses: actions/checkout@v4.2.2
- name: Set up JDK 17
uses: actions/setup-java@v4.7.1
with:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@v4.4.2
- uses: gradle/actions/setup-gradle@v4.4.1
- name: Generate OpenAPI documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
env:
DISABLE_ADDITIONAL_FEATURES: true
- name: Upload OpenAPI Documentation
uses: actions/upload-artifact@v4.6.2
with:
@@ -147,8 +142,6 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: cd frontend && npm ci
- name: Lint frontend
run: cd frontend && npm run lint
- name: Build frontend
run: cd frontend && npm run build
- name: Run frontend tests
@@ -170,7 +163,7 @@ jobs:
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@v4.3.0
uses: actions/checkout@v4.2.2
- name: Set up JDK 17
uses: actions/setup-java@v4.7.1
with:
@@ -212,7 +205,7 @@ jobs:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Java 17
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
@@ -261,7 +254,7 @@ jobs:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 17
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
@@ -270,7 +263,7 @@ jobs:
distribution: "temurin"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
gradle-version: 8.14
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
egress-policy: audit
- name: Checkout main branch first
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup GitHub App Bot
id: setup-bot
+1 -1
View File
@@ -22,6 +22,6 @@ jobs:
egress-policy: audit
- name: "Checkout Repository"
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: "Dependency Review"
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
+2 -2
View File
@@ -36,7 +36,7 @@ jobs:
egress-policy: audit
- name: Check out code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
@@ -54,7 +54,7 @@ jobs:
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
- name: Check licenses for compatibility
run: ./gradlew clean checkLicense
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
egress-policy: audit
- name: Check out the repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Run Labeler
uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 # v5.3.0
+8 -8
View File
@@ -25,7 +25,7 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
@@ -64,7 +64,7 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 21
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
@@ -72,7 +72,7 @@ jobs:
java-version: "21"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
gradle-version: 8.14
@@ -115,7 +115,7 @@ jobs:
egress-policy: audit
- name: Download build artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: stirling-${{ matrix.file_suffix }}binaries
@@ -152,7 +152,7 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 21
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
@@ -160,7 +160,7 @@ jobs:
java-version: "21"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
gradle-version: 8.14
@@ -243,7 +243,7 @@ jobs:
egress-policy: audit
- name: Download build artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: ${{ matrix.platform }}binaries
@@ -306,7 +306,7 @@ jobs:
egress-policy: audit
- name: Download signed artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- name: Display structure of downloaded files
run: ls -R
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
+2 -2
View File
@@ -34,7 +34,7 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 17
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
@@ -42,7 +42,7 @@ jobs:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
gradle-version: 8.14
+4 -4
View File
@@ -27,7 +27,7 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 17
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
@@ -35,7 +35,7 @@ jobs:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
gradle-version: 8.14
@@ -88,7 +88,7 @@ jobs:
egress-policy: audit
- name: Download build artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: binaries${{ matrix.file_suffix }}
- name: Display structure of downloaded files
@@ -166,7 +166,7 @@ jobs:
egress-policy: audit
- name: Download signed artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: signed${{ matrix.file_suffix }}
+2 -2
View File
@@ -39,7 +39,7 @@ jobs:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
@@ -74,6 +74,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.5
uses: github/codeql-action/upload-sarif@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
with:
sarif_file: results.sarif
+2 -2
View File
@@ -34,12 +34,12 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Setup Gradle
uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
- name: Build and analyze with Gradle
env:
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 17
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
@@ -38,7 +38,7 @@ jobs:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
- name: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup GitHub App Bot
id: setup-bot
+3 -3
View File
@@ -29,7 +29,7 @@ jobs:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
@@ -38,7 +38,7 @@ jobs:
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
gradle-version: 8.14
@@ -126,7 +126,7 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
-1
View File
@@ -19,6 +19,5 @@
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
"redhat.vscode-yaml", // YAML extension for Visual Studio Code
"dbaeumer.vscode-eslint", // ESLint extension for TypeScript linting
]
}
+1 -4
View File
@@ -139,8 +139,5 @@
"app/core/src/main/java",
"app/common/src/main/java",
"app/proprietary/src/main/java"
],
"[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
}
]
}
-300
View File
@@ -1,300 +0,0 @@
# Adding New React Tools to Stirling PDF
This guide covers how to add new PDF tools to the React frontend, either by migrating existing Thymeleaf templates or creating entirely new tools.
## Overview
When adding tools, follow this systematic approach using the established patterns and architecture.
## 1. Create Tool Structure
Create these files in the correct directories:
```
frontend/src/hooks/tools/[toolName]/
├── use[ToolName]Parameters.ts # Parameter definitions and validation
└── use[ToolName]Operation.ts # Tool operation logic using useToolOperation
frontend/src/components/tools/[toolName]/
└── [ToolName]Settings.tsx # Settings UI component (if needed)
frontend/src/tools/
└── [ToolName].tsx # Main tool component
```
## 2. Implementation Pattern
Use `useBaseTool` for simplified hook management. This is the recommended approach for all new tools:
**Parameters Hook** (`use[ToolName]Parameters.ts`):
```typescript
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export interface [ToolName]Parameters extends BaseParameters {
// Define your tool-specific parameters here
someOption: boolean;
}
export const defaultParameters: [ToolName]Parameters = {
someOption: false,
};
export const use[ToolName]Parameters = (): BaseParametersHook<[ToolName]Parameters> => {
return useBaseParameters({
defaultParameters,
endpointName: 'your-endpoint-name',
validateFn: (params) => true, // Add validation logic
});
};
```
**Operation Hook** (`use[ToolName]Operation.ts`):
```typescript
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
export const build[ToolName]FormData = (parameters: [ToolName]Parameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
// Add parameters to formData
return formData;
};
export const [toolName]OperationConfig = {
toolType: ToolType.singleFile, // or ToolType.multiFile (buildFormData's file parameter will need to be updated)
buildFormData: build[ToolName]FormData,
operationType: '[toolName]',
endpoint: '/api/v1/category/endpoint-name',
filePrefix: 'processed_', // Will be overridden with translation
defaultParameters,
} as const;
export const use[ToolName]Operation = () => {
const { t } = useTranslation();
return useToolOperation({
...[toolName]OperationConfig,
filePrefix: t('[toolName].filenamePrefix', 'processed') + '_',
getErrorMessage: createStandardErrorHandler(t('[toolName].error.failed', 'Operation failed'))
});
};
```
**Main Component** (`[ToolName].tsx`):
```typescript
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import { use[ToolName]Parameters } from "../hooks/tools/[toolName]/use[ToolName]Parameters";
import { use[ToolName]Operation } from "../hooks/tools/[toolName]/use[ToolName]Operation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
const [ToolName] = (props: BaseToolProps) => {
const { t } = useTranslation();
const base = useBaseTool('[toolName]', use[ToolName]Parameters, use[ToolName]Operation, props);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
placeholder: t("[toolName].files.placeholder", "Select files to get started"),
},
steps: [
// Add settings steps if needed
],
executeButton: {
text: t("[toolName].submit", "Process"),
isVisible: !base.hasResults,
loadingText: t("loading"),
onClick: base.handleExecute,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("[toolName].results.title", "Results"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
[ToolName].tool = () => use[ToolName]Operation;
export default [ToolName] as ToolComponent;
```
**Note**: Some existing tools (like AddPassword, Compress) use a legacy pattern with manual hook management. **Always use the Modern Pattern above for new tools** - it's cleaner, more maintainable, and includes automation support.
## 3. Register Tool in System
Update these files to register your new tool:
**Tool Registry** (`frontend/src/data/useTranslatedToolRegistry.tsx`):
1. Add imports at the top:
```typescript
import [ToolName] from "../tools/[ToolName]";
import { [toolName]OperationConfig } from "../hooks/tools/[toolName]/use[ToolName]Operation";
import [ToolName]Settings from "../components/tools/[toolName]/[ToolName]Settings";
```
2. Add tool entry in the `allTools` object:
```typescript
[toolName]: {
icon: <LocalIcon icon="your-icon-name" width="1.5rem" height="1.5rem" />,
name: t("home.[toolName].title", "Tool Name"),
component: [ToolName],
description: t("home.[toolName].desc", "Tool description"),
categoryId: ToolCategoryId.STANDARD_TOOLS, // or appropriate category
subcategoryId: SubcategoryId.APPROPRIATE_SUBCATEGORY,
maxFiles: -1, // or specific number
endpoints: ["endpoint-name"],
operationConfig: [toolName]OperationConfig,
settingsComponent: [ToolName]Settings, // if settings exist
},
```
## 4. Add Tooltips (Optional but Recommended)
Create user-friendly tooltips to help non-technical users understand your tool. **Use simple, clear language - avoid technical jargon:**
**Tooltip Hook** (`frontend/src/components/tooltips/use[ToolName]Tips.ts`):
```typescript
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const use[ToolName]Tips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("[toolName].tooltip.header.title", "Tool Overview")
},
tips: [
{
title: t("[toolName].tooltip.description.title", "What does this tool do?"),
description: t("[toolName].tooltip.description.text", "Simple explanation in everyday language that non-technical users can understand."),
bullets: [
t("[toolName].tooltip.description.bullet1", "Easy-to-understand benefit 1"),
t("[toolName].tooltip.description.bullet2", "Easy-to-understand benefit 2")
]
}
// Add more tip sections as needed
]
};
};
```
**Add tooltip to your main component:**
```typescript
import { use[ToolName]Tips } from "../components/tooltips/use[ToolName]Tips";
const [ToolName] = (props: BaseToolProps) => {
const tips = use[ToolName]Tips();
// In your steps array:
steps: [
{
title: t("[toolName].steps.settings", "Settings"),
tooltip: tips, // Add this line
content: <[ToolName]Settings ... />
}
]
```
## 5. Add Translations
Update translation files. **Important: Only update `en-GB` files** - other languages are handled separately.
**File to update:** `frontend/public/locales/en-GB/translation.json`
**Required Translation Keys**:
```json
{
"home": {
"[toolName]": {
"title": "Tool Name",
"desc": "Tool description"
}
},
"[toolName]": {
"title": "Tool Name",
"submit": "Process",
"filenamePrefix": "processed",
"files": {
"placeholder": "Select files to get started"
},
"steps": {
"settings": "Settings"
},
"options": {
"title": "Tool Options",
"someOption": "Option Label",
"someOption.desc": "Option description",
"note": "General information about the tool."
},
"results": {
"title": "Results"
},
"error": {
"failed": "Operation failed"
},
"tooltip": {
"header": {
"title": "Tool Overview"
},
"description": {
"title": "What does this tool do?",
"text": "Simple explanation in everyday language",
"bullet1": "Easy-to-understand benefit 1",
"bullet2": "Easy-to-understand benefit 2"
}
}
}
}
```
**Translation Notes:**
- **Only update `en-GB/translation.json`** - other locale files are managed separately
- Use descriptive keys that match your component's `t()` calls
- Include tooltip translations if you created tooltip hooks
- Add `options.*` keys if your tool has settings with descriptions
**Tooltip Writing Guidelines:**
- **Use simple, everyday language** - avoid technical terms like "converts interactive elements"
- **Focus on benefits** - explain what the user gains, not how it works internally
- **Use concrete examples** - "text boxes become regular text" vs "form fields are flattened"
- **Answer user questions** - "What does this do?", "When should I use this?", "What's this option for?"
- **Keep descriptions concise** - 1-2 sentences maximum per section
- **Use bullet points** for multiple benefits or features
## 6. Migration from Thymeleaf
When migrating existing Thymeleaf templates:
1. **Identify Form Parameters**: Look at the original `<form>` inputs to determine parameter structure
2. **Extract Translation Keys**: Find `#{key.name}` references and add them to JSON translations (For many tools these translations will already exist but some parts will be missing)
3. **Map API Endpoint**: Note the `th:action` URL for the operation hook
4. **Preserve Functionality**: Ensure all original form behaviour is replicated which is applicable to V2 react UI
## 7. Testing Your Tool
- Verify tool appears in UI with correct icon and description
- Test with various file sizes and types
- Confirm translations work
- Check error handling
- Test undo functionality
- Verify results display correctly
## Tool Development Patterns
### Three Tool Patterns:
**Pattern 1: Single-File Tools** (Individual processing)
- Backend processes one file per API call
- Set `multiFileEndpoint: false`
- Examples: Compress, Rotate
**Pattern 2: Multi-File Tools** (Batch processing)
- Backend accepts `MultipartFile[]` arrays in single API call
- Set `multiFileEndpoint: true`
- Examples: Split, Merge, Overlay
**Pattern 3: Complex Tools** (Custom processing)
- Tools with complex routing logic or non-standard processing
- Provide `customProcessor` for full control
- Examples: Convert, OCR
-1
View File
@@ -208,7 +208,6 @@ return useToolOperation({
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation)
- **Adding Tools**: See `ADDING_TOOLS.md` for complete guide to creating new PDF tools
## Communication Style
- Be direct and to the point
@@ -8,7 +8,6 @@ import java.util.List;
import java.util.Locale;
import java.util.Properties;
import java.util.function.Predicate;
import java.util.stream.Stream;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
@@ -49,14 +48,6 @@ public class AppConfig {
@Value("${server.port:8080}")
private String serverPort;
@Value("${v2}")
public boolean v2Enabled;
@Bean
public boolean v2Enabled() {
return v2Enabled;
}
/* Commented out Thymeleaf template engine bean - to be removed when frontend migration is complete
@Bean
@ConditionalOnProperty(name = "system.customHTMLFiles", havingValue = "true")
@@ -128,7 +119,7 @@ public class AppConfig {
public boolean rateLimit() {
String rateLimit = System.getProperty("rateLimit");
if (rateLimit == null) rateLimit = System.getenv("rateLimit");
return Boolean.parseBoolean(rateLimit);
return (rateLimit != null) ? Boolean.valueOf(rateLimit) : false;
}
@Bean(name = "RunningInDocker")
@@ -148,8 +139,8 @@ public class AppConfig {
if (!Files.exists(mountInfo)) {
return true;
}
try (Stream<String> lines = Files.lines(mountInfo)) {
return lines.anyMatch(line -> line.contains(" /configs "));
try {
return Files.lines(mountInfo).anyMatch(line -> line.contains(" /configs "));
} catch (IOException e) {
return false;
}
@@ -25,7 +25,6 @@ public class InstallationPathConfig {
private static final String STATIC_PATH;
private static final String TEMPLATES_PATH;
private static final String SIGNATURES_PATH;
private static final String PRIVATE_KEY_PATH;
static {
BASE_PATH = initializeBasePath();
@@ -46,7 +45,6 @@ public class InstallationPathConfig {
STATIC_PATH = CUSTOM_FILES_PATH + "static" + File.separator;
TEMPLATES_PATH = CUSTOM_FILES_PATH + "templates" + File.separator;
SIGNATURES_PATH = CUSTOM_FILES_PATH + "signatures" + File.separator;
PRIVATE_KEY_PATH = CONFIG_PATH + "db" + File.separator + "keys" + File.separator;
}
private static String initializeBasePath() {
@@ -122,8 +120,4 @@ public class InstallationPathConfig {
public static String getSignaturesPath() {
return SIGNATURES_PATH;
}
public static String getPrivateKeyPath() {
return PRIVATE_KEY_PATH;
}
}
@@ -119,7 +119,6 @@ public class ApplicationProperties {
private long loginResetTimeMinutes;
private String loginMethod = "all";
private String customGlobalAPIKey;
private Jwt jwt = new Jwt();
public Boolean isAltLogin() {
return saml2.getEnabled() || oauth2.getEnabled();
@@ -299,15 +298,6 @@ public class ApplicationProperties {
}
}
}
@Data
public static class Jwt {
private boolean enableKeystore = true;
private boolean enableKeyRotation = false;
private boolean enableKeyCleanup = true;
private int keyRetentionDays = 7;
private boolean secureCookie;
}
}
@Data
@@ -372,8 +362,7 @@ public class ApplicationProperties {
public String getBaseTmpDir() {
return baseTmpDir != null && !baseTmpDir.isEmpty()
? baseTmpDir
: java.lang.System.getProperty("java.io.tmpdir").replaceAll("/+$", "")
+ "/stirling-pdf";
: java.lang.System.getProperty("java.io.tmpdir") + "/stirling-pdf";
}
@JsonIgnore
@@ -14,10 +14,8 @@ public class RequestUriUtils {
|| requestURI.startsWith(contextPath + "/images/")
|| requestURI.startsWith(contextPath + "/public/")
|| requestURI.startsWith(contextPath + "/pdfjs/")
|| requestURI.startsWith(contextPath + "/pdfjs-legacy/")
|| requestURI.startsWith(contextPath + "/login")
|| requestURI.startsWith(contextPath + "/error")
|| requestURI.startsWith(contextPath + "/favicon")
|| requestURI.endsWith(".svg")
|| requestURI.endsWith(".png")
|| requestURI.endsWith(".ico")
@@ -37,6 +37,7 @@ public class ConvertHtmlToPDF {
private final CustomHtmlSanitizer customHtmlSanitizer;
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/html/pdf")
@Operation(
summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF",
description =
@@ -46,6 +46,7 @@ public class ConvertMarkdownToPdf {
private final CustomHtmlSanitizer customHtmlSanitizer;
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/markdown/pdf")
@Operation(
summary = "Convert a Markdown file to PDF",
description =
@@ -3,9 +3,9 @@ logging.level.org.springframework=WARN
logging.level.org.hibernate=WARN
logging.level.org.eclipse.jetty=WARN
#logging.level.org.springframework.security.saml2=TRACE
#logging.level.org.springframework.security=DEBUG
logging.level.org.springframework.security=DEBUG
#logging.level.org.opensaml=DEBUG
#logging.level.stirling.software.proprietary.security=DEBUG
logging.level.stirling.software: DEBUG
logging.level.com.zaxxer.hikari=WARN
spring.jpa.open-in-view=false
server.forward-headers-strategy=NATIVE
@@ -57,5 +57,12 @@ spring.main.allow-bean-definition-overriding=true
# Set up a consistent temporary directory location
java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
# V2 features
v2=false
#spring.security.oauth2.resourceserver.jwt.issuer-uri=https://nrlkjfznsavsbmweiyqu.supabase.co/auth/v1
spring.security.oauth2.resourceserver.jwk-set-uri=https://nrlkjfznsavsbmweiyqu.supabase.co/auth/v1/.well-known/jwks.json
spring.security.oauth2.resourceserver.audience=authenticated
logging:
level:
org.springframework.security: DEBUG
your.project.pkg.security: DEBUG
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=لقد تسجل دخولًا إلى
login.alreadyLoggedIn2=أجهزة أخرى. يرجى تسجيل الخروج من الأجهزة وحاول مرة أخرى.
login.toManySessions=لديك عدة جلسات نشطة
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=حجب تلقائي
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Siz artıq daxil olmusunuz
login.alreadyLoggedIn2=cihazlar. Zəhmət olmasa, cihazlardan çıxış edin və yenidən cəhd edin.
login.toManySessions=Həddindən artıq aktiv sessiyanız var
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Avtomatik Gizlətmə
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Вече сте влезли в
login.alreadyLoggedIn2=устройства. Моля, излезте от устройствата и опитайте отново.
login.toManySessions=Имате твърде много активни сесии
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Автоматично редактиране
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=ཁྱེད་རང་
login.alreadyLoggedIn2=སྒྲིག་ཆས་ནང་ནང་འཛུལ་བྱས་ཟིན། སྒྲིག་ཆས་ནས་ཕྱིར་འཐེན་བྱས་ནས་ཡང་བསྐྱར་ཚོད་ལྟ་བྱེད་རོགས།
login.toManySessions=ཁྱེད་ལ་འཛུལ་ཞུགས་བྱས་པའི་གནས་སྐབས་མང་དྲགས་འདུག
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=རང་འགུལ་སྒྲིབ་སྲུང་།
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Ja has iniciat sessió a
login.alreadyLoggedIn2=dispositius. Si us plau, tanca la sessió en els dispositius i torna-ho a intentar.
login.toManySessions=Tens massa sessions actives
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Redacció Automàtica
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Již jste přihlášeni na
login.alreadyLoggedIn2=zařízeních. Odhlaste se prosím z těchto zařízení a zkuste to znovu.
login.toManySessions=Máte příliš mnoho aktivních relací
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Automatické začernění
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Du er allerede logget ind på
login.alreadyLoggedIn2=enheder. Log ud af disse enheder og prøv igen.
login.toManySessions=Du har for mange aktive sessoner
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Auto Rediger
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Sie sind bereits an
login.alreadyLoggedIn2=Geräten angemeldet. Bitte melden Sie sich dort ab und versuchen es dann erneut.
login.toManySessions=Sie haben zu viele aktive Sitzungen
login.logoutMessage=Sie wurden erfolgreich abgemeldet.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Automatisch zensieren/schwärzen
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Είστε ήδη συνδεδεμένοι σε
login.alreadyLoggedIn2=συσκευές. Παρακαλώ αποσυνδεθείτε από τις συσκευές και προσπαθήστε ξανά.
login.toManySessions=Έχετε πάρα πολλές ενεργές συνεδρίες
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Αυτόματη απόκρυψη
@@ -893,7 +893,7 @@ login.rememberme=Remember me
login.invalid=Invalid username or password.
login.locked=Your account has been locked.
login.signinTitle=Please sign in
login.ssoSignIn=Login via Single Sign-On
login.ssoSignIn=Login via Single Sign-on
login.oAuth2AutoCreateDisabled=OAUTH2 Auto-Create User Disabled
login.oAuth2AdminBlockedUser=Registration or logging in of non-registered users is currently blocked. Please contact the administrator.
login.oauth2RequestNotFound=Authorization request not found
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=You are already logged in to
login.alreadyLoggedIn2=devices. Please log out of the devices and try again.
login.toManySessions=You have too many active sessions
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Auto Redact
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=You are already logged in to
login.alreadyLoggedIn2=devices. Please log out of the devices and try again.
login.toManySessions=You have too many active sessions
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Auto Redact
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Ya ha iniciado sesión en
login.alreadyLoggedIn2=dispositivos. Cierre sesión en los dispositivos y vuelva a intentarlo.
login.toManySessions=Tiene demasiadas sesiones activas
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Auto Censurar Texto
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=You are already logged in to
login.alreadyLoggedIn2=devices. Please log out of the devices and try again.
login.toManySessions=You have too many active sessions
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Auto Idatzi
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=شما قبلاً وارد شده‌اید در
login.alreadyLoggedIn2=دستگاه‌ها. لطفاً از دستگاه‌ها خارج شده و دوباره تلاش کنید.
login.toManySessions=شما تعداد زیادی نشست فعال دارید.
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=سانسور خودکار
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Vous êtes déjà connecté sur
login.alreadyLoggedIn2=appareils. Veuillez vous déconnecter des appareils et réessayer.
login.toManySessions=Vous avez trop de sessions actives.
login.logoutMessage=Vous avez été déconnecté.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Caviarder automatiquement
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Tá tú logáilte isteach cheana
login.alreadyLoggedIn2=gléasanna. Logáil amach as na gléasanna agus bain triail eile as.
login.toManySessions=Tá an iomarca seisiún gníomhach agat
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Auto Redact
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=आप पहले से ही
login.alreadyLoggedIn2=उपकरणों में लॉग इन हैं। कृपया उपकरणों से लॉग आउट करें और पुनः प्रयास करें।
login.toManySessions=आपके बहुत सारे सक्रिय सत्र हैं
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=स्वतः गोपनीयकरण
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Već ste se prijavili na
login.alreadyLoggedIn2=ure. Odjavite se s ure i pokušajte ponovo.
login.toManySessions=Imate preko mrežne sesije aktivnih
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Automatsko uređivanje
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Már be van jelentkezve
login.alreadyLoggedIn2=eszközön. Kérjük, jelentkezzen ki az eszközökről és próbálja újra.
login.toManySessions=Túl sok aktív munkamenet
login.logoutMessage=Sikeresen kijelentkezett.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Automatikus kitakarás
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Anda sudah login ke
login.alreadyLoggedIn2=perangkat. Silakan keluar dari perangkat dan coba lagi.
login.toManySessions=Anda memiliki terlalu banyak sesi aktif
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Redaksional Otomatis
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Hai già effettuato l'accesso a
login.alreadyLoggedIn2=dispositivi. Esci dai dispositivi e riprova.
login.toManySessions=Hai troppe sessioni attive
login.logoutMessage=Sei stato disconnesso.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Redazione automatica
@@ -1774,7 +1773,7 @@ audit.dashboard.filter.userPlaceholder=Filtra per utente
audit.dashboard.filter.startDate=Data di inizio
audit.dashboard.filter.endDate=Data di fine
audit.dashboard.filter.apply=Applica filtri
audit.dashboard.filter.reset=Resetta filtri
audit.dashboard.filter.reset=Resetta Filtri
# Table Headers
audit.dashboard.table.id=ID
@@ -1864,7 +1863,7 @@ scannerEffect.submit=Crea una falsa scansione
#home.scannerEffect
home.scannerEffect.title=Falsa scansione
home.scannerEffect.desc=Crea un PDF che sembra scansionato
scannerEffect.tags=scansiona,simula,realistico,converti
scannerEffect.tags=scansiona, simula, realistico, converti
# ScannerEffect advanced settings (frontend)
scannerEffect.advancedSettings=Abilita impostazioni di scansione avanzate
@@ -1886,7 +1885,7 @@ scannerEffect.resolution=Risoluzione (DPI)
home.editTableOfContents.title=Modifica indice
home.editTableOfContents.desc=Aggiungi o modifica segnalibri e sommario nei documenti PDF
editTableOfContents.tags=segnalibri,indice,navigazione,indice analitico,sommario,capitoli,sezioni,struttura
editTableOfContents.tags=segnalibri, indice, navigazione, indice analitico, sommario, capitoli, sezioni, struttura
editTableOfContents.title=Modifica indice
editTableOfContents.header=Aggiungi o modifica sommario PDF
editTableOfContents.replaceExisting=Sostituisci i segnalibri esistenti (deseleziona per aggiungerli a quelli esistenti)
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=すでにログインしています
login.alreadyLoggedIn2=デバイスからログアウトしてもう一度お試しください。
login.toManySessions=アクティブなセッションが多すぎます
login.logoutMessage=ログアウトしました
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=自動墨消し
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=이미 다음에 로그인되어 있습니다
login.alreadyLoggedIn2=개의 기기. 해당 기기에서 로그아웃한 후 다시 시도하세요.
login.toManySessions=활성 세션이 너무 많습니다
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=자동 검열
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=നിങ്ങൾ ഇതിനകം ലോഗിൻ ച
login.alreadyLoggedIn2=ഉപകരണങ്ങളിൽ. ദയവായി ഉപകരണങ്ങളിൽ നിന്ന് ലോഗ് ഔട്ട് ചെയ്ത് വീണ്ടും ശ്രമിക്കുക.
login.toManySessions=നിങ്ങൾക്ക് വളരെയധികം സജീവ സെഷനുകൾ ഉണ്ട്
login.logoutMessage=നിങ്ങൾ ലോഗ് ഔട്ട് ചെയ്തു.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=സ്വയം റെഡാക്റ്റ് ചെയ്യുക
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=U zit reeds ingelogd bij
login.alreadyLoggedIn2=apparaten. U moet u a.u.b. uitloggen van de apparaten en opnieuw proberen.
login.toManySessions=U heeft te veel actieve sessies
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Automatisch censureren
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Du er allerede innlogget på
login.alreadyLoggedIn2=enheter. Logg ut og forsøk igjen
login.toManySessions=Du har for mange aktive økter
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Automatisk Sensurering
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Jesteś już zalogowany na
login.alreadyLoggedIn2=urządzeniach. Wyloguj się z tych urządzeń i spróbuj ponownie.
login.toManySessions=Masz zbyt wiele aktywnych sesji
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Automatyczne zaciemnienie
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Você já está conectado em
login.alreadyLoggedIn2=aparelhos. Por favor saia dos aparelhos e tente novamente.
login.toManySessions=Você tem muitas sessões ativas
login.logoutMessage=Você foi desconectado.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Ocultação de Texto Automática
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Já tem sessão iniciada em
login.alreadyLoggedIn2=dispositivos. Por favor termine sessão nesses dispositivos e tente novamente.
login.toManySessions=Tem demasiadas sessões ativas
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Redação Automática
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=You are already logged in to
login.alreadyLoggedIn2=devices. Please log out of the devices and try again.
login.toManySessions=You have too many active sessions
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Redactare Automată
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Вы уже вошли в
login.alreadyLoggedIn2=устройств(а). Пожалуйста, выйдите из этих устройств и попробуйте снова.
login.toManySessions=У вас слишком много активных сессий
login.logoutMessage=Вы вышли из системы.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Автоматическое редактирование
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=You are already logged in to
login.alreadyLoggedIn2=devices. Please log out of the devices and try again.
login.toManySessions=You have too many active sessions
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Automatické redigovanie
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Prijavljeni ste že v
login.alreadyLoggedIn2=naprave. Odjavite se iz naprav in poskusite znova.
login.toManySessions=Imate preveč aktivnih sej
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Samodejno redigiraj
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Već si prijavljen na
login.alreadyLoggedIn2=uređaja. Odjavi se sa uređaja i pokušaj ponovo.
login.toManySessions=Imaš previše aktivnih sesija
login.logoutMessage=Odjavljen si.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Automatsko cenzurisanje
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Du är redan inloggad på
login.alreadyLoggedIn2=enheter. Logga ut från enheterna och försök igen.
login.toManySessions=Du har för många aktiva sessioner
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Auto-redigera
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=คุณได้เข้าสู่ระบบใน
login.alreadyLoggedIn2=อุปกรณ์แล้ว กรุณาออกจากระบบจากอุปกรณ์ที่ใช้งานอยู่แล้ว จากนั้นลองใหม่อีกครั้ง
login.toManySessions=คุณมีการเข้าสู่ระบบพร้อมกันเกินกว่ากำหนด
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=ซ่อนข้อมูลอัตโนมัติ
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Zaten şu cihazlarda oturum açılmış:
login.alreadyLoggedIn2=Lütfen bu cihazlardan çıkış yaparak tekrar deneyin.
login.toManySessions=Çok fazla aktif oturumunuz var
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Otomatik Karartma
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=Ви вже увійшли до
login.alreadyLoggedIn2=пристроїв (а). Будь ласка, вийдіть із цих пристроїв і спробуйте знову.
login.toManySessions=У вас дуже багато активних сесій
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Автоматичне редагування
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=You are already logged in to
login.alreadyLoggedIn2=devices. Please log out of the devices and try again.
login.toManySessions=You have too many active sessions
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Tự động biên tập
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=您已经登录到了
login.alreadyLoggedIn2=设备,请注销设备后重试。
login.toManySessions=你已经有太多的会话了。请注销一些设备后重试。
login.logoutMessage=您已退出登录。
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=自动删除
@@ -908,7 +908,6 @@ login.alreadyLoggedIn=您已經登入了
login.alreadyLoggedIn2=部裝置。請先從這些裝置登出後再試一次。
login.toManySessions=您有太多使用中的工作階段
login.logoutMessage=您已登出。
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=自動塗黑
@@ -59,17 +59,12 @@ security:
idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider
privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair
spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair
jwt: # This feature is currently under development and not yet fully supported. Do not use in production.
persistence: true # Set to 'true' to enable JWT key store
enableKeyRotation: true # Set to 'true' to enable key pair rotation
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days.
secureCookie: false # Set to 'true' to use secure cookies for JWTs
premium:
key: 00000000-0000-0000-0000-000000000000
enabled: false # Enable license key checks for pro/enterprise features
proFeatures:
database: true # Enable database features
SSOAutoLogin: false
CustomMetadata:
autoUpdateMetadata: false
@@ -132,13 +132,6 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.github.ben-manes.caffeine:caffeine",
"moduleUrl": "https://github.com/ben-manes/caffeine",
"moduleVersion": "3.2.2",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.github.jai-imageio:jai-imageio-core",
"moduleUrl": "https://github.com/jai-imageio/jai-imageio-core",
@@ -175,7 +168,7 @@
{
"moduleName": "com.google.errorprone:error_prone_annotations",
"moduleUrl": "https://errorprone.info/error_prone_annotations",
"moduleVersion": "2.40.0",
"moduleVersion": "2.38.0",
"moduleLicense": "Apache 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -556,27 +549,6 @@
"moduleLicense": "MIT License",
"moduleLicenseUrl": "http://www.opensource.org/licenses/mit-license.php"
},
{
"moduleName": "io.jsonwebtoken:jjwt-api",
"moduleUrl": "https://github.com/jwtk/jjwt",
"moduleVersion": "0.12.6",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.jsonwebtoken:jjwt-impl",
"moduleUrl": "https://github.com/jwtk/jjwt",
"moduleVersion": "0.12.6",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.jsonwebtoken:jjwt-jackson",
"moduleUrl": "https://github.com/jwtk/jjwt",
"moduleVersion": "0.12.6",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.micrometer:micrometer-commons",
"moduleUrl": "https://github.com/micrometer-metrics/micrometer",
@@ -1535,13 +1507,6 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-cache",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "3.5.4",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-data-jpa",
"moduleUrl": "https://spring.io/projects/spring-boot",
@@ -46,9 +46,10 @@ export class DecryptFile {
formData.append('password', password);
}
// Send decryption request
const response = await fetchWithCsrf('/api/v1/security/remove-password', {
const response = await fetch('/api/v1/security/remove-password', {
method: 'POST',
body: formData,
headers: csrfToken ? {'X-XSRF-TOKEN': csrfToken} : undefined,
});
if (response.ok) {
@@ -218,7 +218,7 @@
formData.append('password', password);
// Use handleSingleDownload to send the request
const decryptionResult = await fetchWithCsrf(removePasswordUrl, {method: 'POST', body: formData});
const decryptionResult = await fetch(removePasswordUrl, {method: 'POST', body: formData});
if (decryptionResult && decryptionResult.blob) {
const decryptedBlob = await decryptionResult.blob();
@@ -1,29 +1,3 @@
// Authentication utility for cookie-based JWT
window.JWTManager = {
// Logout - clear cookies and redirect to login
logout: function() {
// Clear JWT cookie manually (fallback)
document.cookie = 'stirling_jwt=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; SameSite=None; Secure';
// Perform logout request to clear server-side session
fetch('/logout', {
method: 'POST',
credentials: 'include'
}).then(response => {
if (response.redirected) {
window.location.href = response.url;
} else {
window.location.href = '/login?logout=true';
}
}).catch(() => {
// If logout fails, let server handle it
window.location.href = '/logout';
});
}
};
window.fetchWithCsrf = async function(url, options = {}) {
function getCsrfToken() {
const cookieValue = document.cookie
@@ -50,18 +24,5 @@ window.fetchWithCsrf = async function(url, options = {}) {
fetchOptions.headers['X-XSRF-TOKEN'] = csrfToken;
}
// Always include credentials to send JWT cookies
fetchOptions.credentials = 'include';
// Make the request
const response = await fetch(url, fetchOptions);
// Handle 401 responses (unauthorized)
if (response.status === 401) {
console.warn('Authentication failed, redirecting to login');
window.JWTManager.logout();
return response;
}
return response;
return fetch(url, fetchOptions);
}
@@ -1,44 +0,0 @@
// JWT Authentication Management Script
// This script handles cookie-based JWT authentication and page access control
(function() {
// Clean up JWT token from URL parameters after OAuth/Login flows
function cleanupTokenFromUrl() {
const urlParams = new URLSearchParams(window.location.search);
const hasToken = urlParams.get('jwt') || urlParams.get('token');
if (hasToken) {
// Clean up URL by removing token parameter
// Token should now be set as cookie by server
urlParams.delete('jwt');
urlParams.delete('token');
const newUrl = window.location.pathname + (urlParams.toString() ? '?' + urlParams.toString() : '');
window.history.replaceState({}, '', newUrl);
}
}
// Initialize JWT handling when page loads
function initializeJWT() {
// Clean up any JWT tokens from URL (OAuth flow)
cleanupTokenFromUrl();
// Authentication is handled server-side
// If user is not authenticated, server will redirect to login
console.log('JWT initialization complete - authentication handled server-side');
}
// No form enhancement needed for cookie-based JWT
// Cookies are automatically sent with form submissions
function enhanceFormSubmissions() {
// Cookie-based JWT is automatically included in form submissions
// No additional processing needed
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
initializeJWT();
});
} else {
initializeJWT();
}
})();
@@ -138,19 +138,5 @@ document.addEventListener('DOMContentLoaded', () => {
tooltipSetup();
setupDropdowns();
fixNavbarDropdownStyles();
// Setup logout button functionality
const logoutButton = document.querySelector('a[href="/logout"]');
if (logoutButton) {
logoutButton.addEventListener('click', function(event) {
event.preventDefault();
if (window.JWTManager) {
window.JWTManager.logout();
} else {
// Fallback if JWTManager is not available
window.location.href = '/logout';
}
});
}
});
window.addEventListener('resize', fixNavbarDropdownStyles);
@@ -102,7 +102,7 @@ async function fetchEndpointData() {
refreshBtn.classList.add('refreshing');
refreshBtn.disabled = true;
const response = await fetchWithCsrf('/api/v1/info/load/all');
const response = await fetch('/api/v1/info/load/all');
if (!response.ok) {
throw new Error('Network response was not ok');
}
@@ -390,13 +390,8 @@
key.includes('clientSubmissionOrder') ||
key.includes('lastSubmitTime') ||
key.includes('lastClientId') ||
key.includes('stirling_jwt') ||
key.includes('JSESSIONID') ||
key.includes('XSRF-TOKEN') ||
key.includes('remember-me') ||
key.includes('auth') ||
key.includes('token') ||
key.includes('session') ||
key.includes('posthog') || key.includes('ssoRedirectAttempts') || key.includes('lastRedirectAttempt') || key.includes('surveyVersion') ||
key.includes('pageViews');
}
+2 -12
View File
@@ -1,15 +1,9 @@
repositories {
maven { url = "https://build.shibboleth.net/maven/releases" }
}
ext {
jwtVersion = '0.12.6'
}
bootRun {
enabled = false
}
spotless {
java {
target 'src/**/java/**/*.java'
@@ -43,14 +37,14 @@ dependencies {
api "org.springframework.security:spring-security-core:$springSecuritySamlVersion"
api "org.springframework.security:spring-security-web:$springSecuritySamlVersion"
api "org.springframework.security:spring-security-config:$springSecuritySamlVersion"
api("org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.5.4")
api "org.springframework.security:spring-security-oauth2-jose:$springSecuritySamlVersion"
api "org.springframework.security:spring-security-saml2-service-provider:$springSecuritySamlVersion"
api 'org.springframework.boot:spring-boot-starter-jetty'
api 'org.springframework.boot:spring-boot-starter-security'
api 'org.springframework.boot:spring-boot-starter-data-jpa'
api 'org.springframework.boot:spring-boot-starter-oauth2-client'
api 'org.springframework.boot:spring-boot-starter-mail'
api 'org.springframework.boot:spring-boot-starter-cache'
api 'com.github.ben-manes.caffeine:caffeine'
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.35'
implementation 'com.bucket4j:bucket4j_jdk17-core:8.14.0'
@@ -60,10 +54,6 @@ dependencies {
// implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.3.RELEASE' // Removed - UI moved to React frontend
api 'io.micrometer:micrometer-registry-prometheus'
implementation 'com.unboundid.product.scim2:scim2-sdk-client:4.0.0'
api "io.jsonwebtoken:jjwt-api:$jwtVersion"
runtimeOnly "io.jsonwebtoken:jjwt-impl:$jwtVersion"
runtimeOnly "io.jsonwebtoken:jjwt-jackson:$jwtVersion"
runtimeOnly 'com.h2database:h2:2.3.232' // Don't upgrade h2database
runtimeOnly 'org.postgresql:postgresql:42.7.7'
constraints {
@@ -1,7 +1,6 @@
package stirling.software.proprietary.security;
import java.io.IOException;
import java.util.Map;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
@@ -18,8 +17,6 @@ import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
@@ -27,17 +24,13 @@ import stirling.software.proprietary.security.service.UserService;
public class CustomAuthenticationSuccessHandler
extends SavedRequestAwareAuthenticationSuccessHandler {
private final LoginAttemptService loginAttemptService;
private final UserService userService;
private final JwtServiceInterface jwtService;
private LoginAttemptService loginAttemptService;
private UserService userService;
public CustomAuthenticationSuccessHandler(
LoginAttemptService loginAttemptService,
UserService userService,
JwtServiceInterface jwtService) {
LoginAttemptService loginAttemptService, UserService userService) {
this.loginAttemptService = loginAttemptService;
this.userService = userService;
this.jwtService = jwtService;
}
@Override
@@ -53,31 +46,23 @@ public class CustomAuthenticationSuccessHandler
}
loginAttemptService.loginSucceeded(userName);
if (jwtService.isJwtEnabled()) {
String jwt =
jwtService.generateToken(
authentication, Map.of("authType", AuthenticationType.WEB));
jwtService.addToken(response, jwt);
log.debug("JWT generated for user: {}", userName);
// Get the saved request
HttpSession session = request.getSession(false);
SavedRequest savedRequest =
(session != null)
? (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST")
: null;
getRedirectStrategy().sendRedirect(request, response, "/");
if (savedRequest != null
&& !RequestUriUtils.isStaticResource(
request.getContextPath(), savedRequest.getRedirectUrl())) {
// Redirect to the original destination
super.onAuthenticationSuccess(request, response, authentication);
} else {
// Get the saved request
HttpSession session = request.getSession(false);
SavedRequest savedRequest =
(session != null)
? (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST")
: null;
if (savedRequest != null
&& !RequestUriUtils.isStaticResource(
request.getContextPath(), savedRequest.getRedirectUrl())) {
// Redirect to the original destination
super.onAuthenticationSuccess(request, response, authentication);
} else {
// No saved request or it's a static resource, redirect to home page
getRedirectStrategy().sendRedirect(request, response, "/");
}
// Redirect to the root URL (considering context path)
getRedirectStrategy().sendRedirect(request, response, "/");
}
// super.onAuthenticationSuccess(request, response, authentication);
}
}
@@ -33,7 +33,6 @@ import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.saml2.CertificateUtils;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.JwtServiceInterface;
@Slf4j
@RequiredArgsConstructor
@@ -41,18 +40,15 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
public static final String LOGOUT_PATH = "/login?logout=true";
private final ApplicationProperties.Security securityProperties;
private final ApplicationProperties applicationProperties;
private final AppConfig appConfig;
private final JwtServiceInterface jwtService;
@Override
@Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC)
public void onLogoutSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException {
if (!response.isCommitted()) {
if (authentication != null) {
if (authentication instanceof Saml2Authentication samlAuthentication) {
@@ -71,9 +67,6 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
authentication.getClass().getSimpleName());
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
} else if (!jwtService.extractToken(request).isBlank()) {
jwtService.clearToken(response);
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
} else {
// Redirect to login page after logout
String path = checkForErrors(request);
@@ -89,7 +82,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
Saml2Authentication samlAuthentication)
throws IOException {
SAML2 samlConf = securityProperties.getSaml2();
SAML2 samlConf = applicationProperties.getSecurity().getSaml2();
String registrationId = samlConf.getRegistrationId();
CustomSaml2AuthenticatedPrincipal principal =
@@ -134,7 +127,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
OAuth2AuthenticationToken oAuthToken)
throws IOException {
String registrationId;
OAUTH2 oauth = securityProperties.getOauth2();
OAUTH2 oauth = applicationProperties.getSecurity().getOauth2();
String path = checkForErrors(request);
String redirectUrl = UrlUtils.getOrigin(request) + "/login?" + path;
@@ -43,6 +43,7 @@ public class InitialSecuritySetup {
}
}
userService.migrateOauth2ToSSO();
assignUsersToDefaultTeamIfMissing();
initializeInternalApiUser();
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
@@ -1,22 +0,0 @@
package stirling.software.proprietary.security;
import java.io.IOException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException)
throws IOException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
}
}
@@ -7,14 +7,3 @@ import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
@Component
@RequiredArgsConstructor
public class RateLimitResetScheduler {
private final IPRateLimitingFilter rateLimitingFilter;
@Scheduled(cron = "0 0 0 * * MON") // At 00:00 every Monday TODO: configurable
public void resetRateLimit() {
rateLimitingFilter.resetRequestCounts();
}
}
@@ -74,11 +74,8 @@ public class AccountWebController {
// @GetMapping("/login")
public String login(HttpServletRequest request, Model model, Authentication authentication) {
// If the user is already authenticated and it's not a logout scenario, redirect them to the
// home page.
if (authentication != null
&& authentication.isAuthenticated()
&& request.getParameter("logout") == null) {
// If the user is already authenticated, redirect them to the home page.
if (authentication != null && authentication.isAuthenticated()) {
return "redirect:/";
}
@@ -184,7 +181,7 @@ public class AccountWebController {
errorOAuth = "login.relyingPartyRegistrationNotFound";
// Valid InResponseTo was not available from the validation context, unable to
// evaluate
case "invalid_in_response_to" -> errorOAuth = "login.invalidInResponseTo";
case "invalid_in_response_to" -> errorOAuth = "login.invalid_in_response_to";
case "not_authentication_provider_found" ->
errorOAuth = "login.not_authentication_provider_found";
}
@@ -1,31 +0,0 @@
package stirling.software.proprietary.security.configuration;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.github.benmanes.caffeine.cache.Caffeine;
@Configuration
@EnableCaching
public class CacheConfig {
@Value("${security.jwt.keyRetentionDays}")
private int keyRetentionDays;
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(
Caffeine.newBuilder()
.maximumSize(1000) // Make configurable?
.expireAfterWrite(Duration.ofDays(keyRetentionDays))
.recordStats());
return cacheManager;
}
}
@@ -0,0 +1,311 @@
package stirling.software.proprietary.security.configuration;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationListener;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.event.AbstractAuthenticationEvent;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
/**
* Security configuration for Supabase-issued JWTs only.
*
* Requires:
*
* spring:
* security:
* oauth2:
* resourceserver:
* jwt:
* issuer-uri: https://<project-id>.supabase.co/auth/v1
*
* Optional logging (application.yml):
* logging:
* level:
* org.springframework.security: DEBUG
* your.project.pkg.security: DEBUG
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class);
/** Your Supabase project ref, e.g. abcd1234efgh5678ijkl */
@Value("${app.supabase.project-ref:nrlkjfznsavsbmweiyqu}")
private String projectRef;
/** Optional audience to enforce (leave empty to skip) */
@Value("${app.jwt.expected-aud:}")
private String expectedAud;
/** Clock skew in seconds for exp validation */
@Value("${app.jwt.clock-skew-seconds:120}")
private long clockSkewSeconds;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, JwtDecoder jwtDecoder) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// allow CORS preflight only
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
// public endpoints
.requestMatchers(
new AntPathRequestMatcher("/actuator/health"),
new AntPathRequestMatcher("/public/**"),
new AntPathRequestMatcher("/images/**"),
new AntPathRequestMatcher("/css/**"),
new AntPathRequestMatcher("/js/**")
).permitAll()
// everything else requires auth
.anyRequest().authenticated()
)
.addFilterBefore(new VerboseAuthLoggingFilter(), BearerTokenAuthenticationFilter.class)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())
.accessDeniedHandler(new BearerTokenAccessDeniedHandler())
)
.oauth2ResourceServer(oauth -> oauth
.jwt(jwt -> jwt
.decoder(jwtDecoder)
.jwtAuthenticationConverter(this::toAuthentication)
)
);
return http.build();
}
/** CORS config so browser can send Authorization on real requests. */
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOrigins(List.of(
"http://localhost:3000", // dev
"http://localhost:5173",
"http://localhost:8080",
"https://your-frontend.example"// prod
));
cfg.setAllowedMethods(List.of("GET","POST","PUT","PATCH","DELETE","OPTIONS"));
cfg.setAllowedHeaders(List.of("Authorization","Content-Type","X-Requested-With","Accept","Origin"));
cfg.setExposedHeaders(List.of("WWW-Authenticate"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
}
/** JWKS-based decoder + custom validator (issuer/exp/aud). */
@Bean
JwtDecoder jwtDecoder() {
String issuer = "https://" + projectRef + ".supabase.co/auth/v1"; // no trailing slash
String jwks = issuer + "/.well-known/jwks.json";
log.info("Configuring JWT decoder with JWKS: {}", jwks);
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwks).build();
decoder.setJwtValidator(new CompositeValidator(issuer, expectedAud, Duration.ofSeconds(clockSkewSeconds)));
if (expectedAud == null || expectedAud.isBlank()) {
log.info("JWT validation: enforcing issuer='{}' and exp (skew {}s)", issuer, clockSkewSeconds);
} else {
log.info("JWT validation: enforcing issuer='{}', audience='{}', and exp (skew {}s)",
issuer, expectedAud, clockSkewSeconds);
}
return decoder;
}
/** Map claims -> authorities. DEBUG: hotmail.com => ROLE_ADMIN. */
private AbstractAuthenticationToken toAuthentication(Jwt jwt) {
List<GrantedAuthority> authorities = new ArrayList<>();
// Supabase default role -> ROLE_authenticated (optional but handy)
String supabaseRole = jwt.getClaimAsString("role"); // often "authenticated"
if (supabaseRole != null && !supabaseRole.isBlank()) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + supabaseRole));
}
// Your custom app_role (if you add it via Access Token Hook) -> ROLE_*
String appRole = jwt.getClaimAsString("app_role");
if (appRole != null && !appRole.isBlank()) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + appRole.toUpperCase()));
}
// DEBUG RULE: email domain admin
String email = jwt.getClaimAsString("email");
if (email != null && email.toLowerCase(Locale.ROOT).endsWith("@hotmail.com")) {
authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
} else {
// Give a basic user role for convenience while debugging (optional)
authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
}
// Optional permissions array -> PERM_*
List<String> perms = jwt.getClaimAsStringList("permissions");
if (perms != null) {
perms.stream()
.filter(p -> p != null && !p.isBlank())
.map(p -> new SimpleGrantedAuthority("PERM_" + p))
.forEach(authorities::add);
}
String principalName = (email != null && !email.isBlank()) ? email : jwt.getSubject();
if (log.isDebugEnabled()) {
log.debug("JWT accepted: sub='{}', email='{}', supabase.role='{}', app_role='{}', permissions={}",
jwt.getSubject(), email, supabaseRole, appRole, perms);
log.debug("Granted authorities: {}", authorities.stream()
.map(GrantedAuthority::getAuthority).collect(Collectors.toList()));
}
return new JwtAuthenticationToken(jwt, authorities, principalName);
}
/** Logs authentication lifecycle events (success/failure). */
@Bean
ApplicationListener<AbstractAuthenticationEvent> authenticationEventsLogger() {
return event -> {
try {
if (event.getSource() instanceof AbstractAuthenticationToken auth) {
String type = event.getClass().getSimpleName();
String name = auth.getName();
String authorities = auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).collect(Collectors.joining(","));
log.debug("[AuthEvent] {} principal='{}' authorities='{}' details={}",
type, name, authorities, auth.getDetails());
} else {
log.debug("[AuthEvent] {} source={}", event.getClass().getSimpleName(), event.getSource());
}
} catch (Exception e) {
log.warn("Failed to log authentication event", e);
}
};
}
/** Super-chatty per-request logger around bearer processing; never logs raw token. */
static class VerboseAuthLoggingFilter extends OncePerRequestFilter {
private static final Logger flog = LoggerFactory.getLogger(VerboseAuthLoggingFilter.class);
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
boolean hasBearer = authHeader != null && authHeader.startsWith("Bearer ");
if (flog.isDebugEnabled()) {
flog.debug("[REQ] {} {} AuthorizationHeaderPresent={} (token hidden)",
request.getMethod(), request.getRequestURI(), hasBearer);
}
try {
filterChain.doFilter(request, response);
} catch (InvalidBearerTokenException ibte) {
flog.warn("[AUTH] Invalid bearer token: {}", ibte.getMessage());
throw ibte;
} catch (Exception ex) {
flog.error("[AUTH] Unexpected auth error: {}", ex.toString(), ex);
throw ex;
}
var auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
if (auth instanceof JwtAuthenticationToken jwtAuth) {
String authorities = jwtAuth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).collect(Collectors.joining(","));
flog.debug("[AUTH] OK principal='{}' authorities='{}'", jwtAuth.getName(), authorities);
} else {
flog.debug("[AUTH] No authentication established");
}
}
}
/** Validator: issuer == expected, not expired (with skew), optional audience. */
static final class CompositeValidator implements OAuth2TokenValidator<Jwt> {
private final String expectedIssuer; // not null
private final String expectedAudienceOrNull; // may be null/blank
private final Duration skew;
CompositeValidator(String expectedIssuer, String expectedAudienceOrNull, Duration skew) {
this.expectedIssuer = Objects.requireNonNull(expectedIssuer);
this.expectedAudienceOrNull = (expectedAudienceOrNull != null && !expectedAudienceOrNull.isBlank())
? expectedAudienceOrNull : null;
this.skew = Objects.requireNonNull(skew);
}
@Override
public OAuth2TokenValidatorResult validate(Jwt token) {
List<OAuth2Error> errors = new ArrayList<>();
String iss = token.getIssuer() != null ? token.getIssuer().toString() : null;
if (iss == null || !iss.equals(expectedIssuer)) {
errors.add(new OAuth2Error("invalid_token", "Invalid issuer: " + iss, null));
}
Instant exp = token.getExpiresAt();
if (exp == null) {
errors.add(new OAuth2Error("invalid_token", "Missing exp claim", null));
} else if (exp.isBefore(Instant.now().minus(skew))) {
errors.add(new OAuth2Error("invalid_token", "Token expired at " + exp, null));
}
if (expectedAudienceOrNull != null) {
List<String> aud = token.getAudience();
if (aud == null || !aud.contains(expectedAudienceOrNull)) {
errors.add(new OAuth2Error("invalid_token", "Missing/invalid audience: " + expectedAudienceOrNull, null));
}
}
if (!errors.isEmpty()) {
errors.forEach(e -> log.warn("JWT validation error: {} - {}", e.getErrorCode(), e.getDescription()));
return OAuth2TokenValidatorResult.failure(errors);
}
return OAuth2TokenValidatorResult.success();
}
}
}
@@ -1,373 +0,0 @@
package stirling.software.proprietary.security.configuration;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Lazy;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.savedrequest.NullRequestCache;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.CustomAuthenticationFailureHandler;
import stirling.software.proprietary.security.CustomAuthenticationSuccessHandler;
import stirling.software.proprietary.security.CustomLogoutSuccessHandler;
import stirling.software.proprietary.security.JwtAuthenticationEntryPoint;
import stirling.software.proprietary.security.database.repository.JPATokenRepositoryImpl;
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
import stirling.software.proprietary.security.filter.FirstLoginFilter;
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
import stirling.software.proprietary.security.filter.JwtAuthenticationFilter;
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationFailureHandler;
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationSuccessHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationSuccessHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2ResponseAuthenticationConverter;
import stirling.software.proprietary.security.service.CustomOAuth2UserService;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@Slf4j
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@DependsOn("runningProOrHigher")
public class SecurityConfiguration {
private final CustomUserDetailsService userDetailsService;
private final UserService userService;
private final boolean loginEnabledValue;
private final boolean runningProOrHigher;
private final ApplicationProperties.Security securityProperties;
private final AppConfig appConfig;
private final UserAuthenticationFilter userAuthenticationFilter;
private final JwtServiceInterface jwtService;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final LoginAttemptService loginAttemptService;
private final FirstLoginFilter firstLoginFilter;
private final SessionPersistentRegistry sessionRegistry;
private final PersistentLoginRepository persistentLoginRepository;
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations;
private final OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver;
public SecurityConfiguration(
PersistentLoginRepository persistentLoginRepository,
CustomUserDetailsService userDetailsService,
@Lazy UserService userService,
@Qualifier("loginEnabled") boolean loginEnabledValue,
@Qualifier("runningProOrHigher") boolean runningProOrHigher,
AppConfig appConfig,
ApplicationProperties.Security securityProperties,
UserAuthenticationFilter userAuthenticationFilter,
JwtServiceInterface jwtService,
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
LoginAttemptService loginAttemptService,
FirstLoginFilter firstLoginFilter,
SessionPersistentRegistry sessionRegistry,
@Autowired(required = false) GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper,
@Autowired(required = false)
RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations,
@Autowired(required = false)
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver) {
this.userDetailsService = userDetailsService;
this.userService = userService;
this.loginEnabledValue = loginEnabledValue;
this.runningProOrHigher = runningProOrHigher;
this.appConfig = appConfig;
this.securityProperties = securityProperties;
this.userAuthenticationFilter = userAuthenticationFilter;
this.jwtService = jwtService;
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
this.loginAttemptService = loginAttemptService;
this.firstLoginFilter = firstLoginFilter;
this.sessionRegistry = sessionRegistry;
this.persistentLoginRepository = persistentLoginRepository;
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations;
this.saml2AuthenticationRequestResolver = saml2AuthenticationRequestResolver;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
if (securityProperties.getCsrfDisabled() || !loginEnabledValue) {
http.csrf(CsrfConfigurer::disable);
}
if (loginEnabledValue) {
boolean v2Enabled = appConfig.v2Enabled();
if (v2Enabled) {
http.addFilterBefore(
jwtAuthenticationFilter(),
UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(
exceptionHandling ->
exceptionHandling.authenticationEntryPoint(
jwtAuthenticationEntryPoint));
}
http.addFilterBefore(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(rateLimitingFilter(), UserAuthenticationFilter.class)
.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class);
if (!securityProperties.getCsrfDisabled()) {
CookieCsrfTokenRepository cookieRepo =
CookieCsrfTokenRepository.withHttpOnlyFalse();
CsrfTokenRequestAttributeHandler requestHandler =
new CsrfTokenRequestAttributeHandler();
requestHandler.setCsrfRequestAttributeName(null);
http.csrf(
csrf ->
csrf.ignoringRequestMatchers(
request -> {
String apiKey = request.getHeader("X-API-KEY");
// If there's no API key, don't ignore CSRF
// (return false)
if (apiKey == null || apiKey.trim().isEmpty()) {
return false;
}
// Validate API key using existing UserService
try {
Optional<User> user =
userService.getUserByApiKey(apiKey);
// If API key is valid, ignore CSRF (return
// true)
// If API key is invalid, don't ignore CSRF
// (return false)
return user.isPresent();
} catch (Exception e) {
// If there's any error validating the API
// key, don't ignore CSRF
return false;
}
})
.csrfTokenRepository(cookieRepo)
.csrfTokenRequestHandler(requestHandler));
}
http.sessionManagement(
sessionManagement -> {
if (v2Enabled) {
sessionManagement.sessionCreationPolicy(
SessionCreationPolicy.STATELESS);
} else {
sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(10)
.maxSessionsPreventsLogin(false)
.sessionRegistry(sessionRegistry)
.expiredUrl("/login?logout=true");
}
});
http.authenticationProvider(daoAuthenticationProvider());
http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache()));
http.logout(
logout ->
logout.logoutRequestMatcher(
PathPatternRequestMatcher.withDefaults()
.matcher("/logout"))
.logoutSuccessHandler(
new CustomLogoutSuccessHandler(
securityProperties, appConfig, jwtService))
.clearAuthentication(true)
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID", "remember-me", "stirling_jwt"));
http.rememberMe(
rememberMeConfigurer -> // Use the configurator directly
rememberMeConfigurer
.tokenRepository(persistentTokenRepository())
.tokenValiditySeconds( // 14 days
14 * 24 * 60 * 60)
.userDetailsService( // Your existing UserDetailsService
userDetailsService)
.useSecureCookie( // Enable secure cookie
true)
.rememberMeParameter( // Form parameter name
"remember-me")
.rememberMeCookieName( // Cookie name
"remember-me")
.alwaysRemember(false));
http.authorizeHttpRequests(
authz ->
authz.requestMatchers(
req -> {
String uri = req.getRequestURI();
String contextPath = req.getContextPath();
// Remove the context path from the URI
String trimmedUri =
uri.startsWith(contextPath)
? uri.substring(
contextPath.length())
: uri;
return trimmedUri.startsWith("/login")
|| trimmedUri.startsWith("/oauth")
|| trimmedUri.startsWith("/saml2")
|| trimmedUri.endsWith(".svg")
|| trimmedUri.startsWith("/register")
|| trimmedUri.startsWith("/error")
|| trimmedUri.startsWith("/images/")
|| trimmedUri.startsWith("/public/")
|| trimmedUri.startsWith("/css/")
|| trimmedUri.startsWith("/fonts/")
|| trimmedUri.startsWith("/js/")
|| trimmedUri.startsWith("/pdfjs/")
|| trimmedUri.startsWith("/pdfjs-legacy/")
|| trimmedUri.startsWith("/favicon")
|| trimmedUri.startsWith(
"/api/v1/info/status")
|| trimmedUri.startsWith("/v1/api-docs")
|| uri.contains("/v1/api-docs");
})
.permitAll()
.anyRequest()
.authenticated());
// Handle User/Password Logins
if (securityProperties.isUserPass()) {
http.formLogin(
formLogin ->
formLogin
.loginPage("/login")
.successHandler(
new CustomAuthenticationSuccessHandler(
loginAttemptService,
userService,
jwtService))
.failureHandler(
new CustomAuthenticationFailureHandler(
loginAttemptService, userService))
.permitAll());
}
// Handle OAUTH2 Logins
if (securityProperties.isOauth2Active()) {
http.oauth2Login(
oauth2 ->
oauth2.loginPage("/oauth2")
/*
This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database.
If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser'
is set as true, else login fails with an error message advising the same.
*/
.successHandler(
new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
securityProperties.getOauth2(),
userService,
jwtService))
.failureHandler(
new CustomOAuth2AuthenticationFailureHandler())
// Add existing Authorities from the database
.userInfoEndpoint(
userInfoEndpoint ->
userInfoEndpoint
.oidcUserService(
new CustomOAuth2UserService(
securityProperties,
userService,
loginAttemptService))
.userAuthoritiesMapper(
oAuth2userAuthoritiesMapper))
.permitAll());
}
// Handle SAML
if (securityProperties.isSaml2Active() && runningProOrHigher) {
OpenSaml4AuthenticationProvider authenticationProvider =
new OpenSaml4AuthenticationProvider();
authenticationProvider.setResponseAuthenticationConverter(
new CustomSaml2ResponseAuthenticationConverter(userService));
http.authenticationProvider(authenticationProvider)
.saml2Login(
saml2 -> {
try {
saml2.loginPage("/saml2")
.relyingPartyRegistrationRepository(
saml2RelyingPartyRegistrations)
.authenticationManager(
new ProviderManager(authenticationProvider))
.successHandler(
new CustomSaml2AuthenticationSuccessHandler(
loginAttemptService,
securityProperties.getSaml2(),
userService,
jwtService))
.failureHandler(
new CustomSaml2AuthenticationFailureHandler())
.authenticationRequestResolver(
saml2AuthenticationRequestResolver);
} catch (Exception e) {
log.error("Error configuring SAML 2 login", e);
throw new RuntimeException(e);
}
});
}
} else {
log.debug("Login is not enabled.");
http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll());
}
return http.build();
}
public DaoAuthenticationProvider daoAuthenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public IPRateLimitingFilter rateLimitingFilter() {
// Example limit TODO add config level
int maxRequestsPerIp = 1000000;
return new IPRateLimitingFilter(maxRequestsPerIp, maxRequestsPerIp);
}
@Bean
public PersistentTokenRepository persistentTokenRepository() {
return new JPATokenRepositoryImpl(persistentLoginRepository);
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(
jwtService,
userService,
userDetailsService,
jwtAuthenticationEntryPoint,
securityProperties);
}
}
@@ -43,7 +43,7 @@ import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequ
@Tag(name = "Admin Settings", description = "Admin-only Settings Management APIs")
@RequestMapping("/api/v1/admin/settings")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("hasRole('ADMIN')")
@Slf4j
public class AdminSettingsController {
@@ -105,9 +105,7 @@ public class UserController {
if (user.getUsername().equals(newUsername)) {
return new RedirectView("/account?messageType=usernameExists", true);
}
if (!userService.isPasswordCorrect(user, currentPassword)) {
return new RedirectView("/account?messageType=incorrectPassword", true);
}
if (!user.getUsername().equals(newUsername) && userService.usernameExists(newUsername)) {
return new RedirectView("/account?messageType=usernameExists", true);
}
@@ -141,9 +139,7 @@ public class UserController {
return new RedirectView("/change-creds?messageType=userNotFound", true);
}
User user = userOpt.get();
if (!userService.isPasswordCorrect(user, currentPassword)) {
return new RedirectView("/change-creds?messageType=incorrectPassword", true);
}
userService.changePassword(user, newPassword);
userService.changeFirstUse(user, false);
// Logout using Spring's utility
@@ -169,9 +165,7 @@ public class UserController {
return new RedirectView("/account?messageType=userNotFound", true);
}
User user = userOpt.get();
if (!userService.isPasswordCorrect(user, currentPassword)) {
return new RedirectView("/account?messageType=incorrectPassword", true);
}
userService.changePassword(user, newPassword);
// Logout using Spring's utility
new SecurityContextLogoutHandler().logout(request, response, null);
@@ -1,204 +0,0 @@
package stirling.software.proprietary.security.filter;
import static stirling.software.common.util.RequestUriUtils.isStaticResource;
import static stirling.software.proprietary.security.model.AuthenticationType.*;
import static stirling.software.proprietary.security.model.AuthenticationType.SAML2;
import java.io.IOException;
import java.sql.SQLException;
import java.util.Map;
import java.util.Optional;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.UserService;
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtServiceInterface jwtService;
private final UserService userService;
private final CustomUserDetailsService userDetailsService;
private final AuthenticationEntryPoint authenticationEntryPoint;
private final ApplicationProperties.Security securityProperties;
public JwtAuthenticationFilter(
JwtServiceInterface jwtService,
UserService userService,
CustomUserDetailsService userDetailsService,
AuthenticationEntryPoint authenticationEntryPoint,
ApplicationProperties.Security securityProperties) {
this.jwtService = jwtService;
this.userService = userService;
this.userDetailsService = userDetailsService;
this.authenticationEntryPoint = authenticationEntryPoint;
this.securityProperties = securityProperties;
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (!jwtService.isJwtEnabled()) {
filterChain.doFilter(request, response);
return;
}
if (isStaticResource(request.getContextPath(), request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
}
if (!apiKeyExists(request, response)) {
String jwtToken = jwtService.extractToken(request);
if (jwtToken == null) {
// Any unauthenticated requests should redirect to /login
String requestURI = request.getRequestURI();
String contextPath = request.getContextPath();
if (!requestURI.startsWith(contextPath + "/login")) {
response.sendRedirect("/login");
return;
}
}
try {
jwtService.validateToken(jwtToken);
} catch (AuthenticationFailureException e) {
jwtService.clearToken(response);
handleAuthenticationFailure(request, response, e);
return;
}
Map<String, Object> claims = jwtService.extractClaims(jwtToken);
String tokenUsername = claims.get("sub").toString();
try {
authenticate(request, claims);
} catch (SQLException | UnsupportedProviderException e) {
log.error("Error processing user authentication for user: {}", tokenUsername, e);
handleAuthenticationFailure(
request,
response,
new AuthenticationFailureException(
"Error processing user authentication", e));
return;
}
}
filterChain.doFilter(request, response);
}
private boolean apiKeyExists(HttpServletRequest request, HttpServletResponse response)
throws IOException, ServletException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
String apiKey = request.getHeader("X-API-KEY");
if (apiKey != null && !apiKey.isBlank()) {
try {
Optional<User> user = userService.getUserByApiKey(apiKey);
if (user.isEmpty()) {
handleAuthenticationFailure(
request,
response,
new AuthenticationFailureException("Invalid API Key"));
return false;
}
authentication =
new ApiKeyAuthenticationToken(
user.get(), apiKey, user.get().getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
return true;
} catch (AuthenticationException e) {
handleAuthenticationFailure(
request,
response,
new AuthenticationFailureException("Invalid API Key", e));
return false;
}
}
return false;
}
return true;
}
private void authenticate(HttpServletRequest request, Map<String, Object> claims)
throws SQLException, UnsupportedProviderException {
String username = claims.get("sub").toString();
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
processUserAuthenticationType(claims, username);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (userDetails != null) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
} else {
throw new UsernameNotFoundException("User not found: " + username);
}
}
}
private void processUserAuthenticationType(Map<String, Object> claims, String username)
throws SQLException, UnsupportedProviderException {
AuthenticationType authenticationType =
AuthenticationType.valueOf(claims.getOrDefault("authType", WEB).toString());
log.debug("Processing {} login for {} user", authenticationType, username);
switch (authenticationType) {
case OAUTH2 -> {
ApplicationProperties.Security.OAUTH2 oauth2Properties =
securityProperties.getOauth2();
userService.processSSOPostLogin(
username, oauth2Properties.getAutoCreateUser(), OAUTH2);
}
case SAML2 -> {
ApplicationProperties.Security.SAML2 saml2Properties =
securityProperties.getSaml2();
userService.processSSOPostLogin(
username, saml2Properties.getAutoCreateUser(), SAML2);
}
}
}
private void handleAuthenticationFailure(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException)
throws IOException, ServletException {
authenticationEntryPoint.commence(request, response, authException);
}
}
@@ -9,6 +9,7 @@ import org.springframework.context.annotation.Lazy;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.session.SessionInformation;
import org.springframework.security.core.userdetails.UserDetails;
@@ -63,7 +64,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
return;
}
String requestURI = request.getRequestURI();
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// Check for session expiration (unsure if needed)
@@ -92,9 +92,14 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
response.getWriter().write("Invalid API Key.");
return;
}
authentication =
new ApiKeyAuthenticationToken(
user.get(), apiKey, user.get().getAuthorities());
List<SimpleGrantedAuthority> authorities =
user.get().getAuthorities().stream()
.map(
authority ->
new SimpleGrantedAuthority(
authority.getAuthority()))
.toList();
authentication = new ApiKeyAuthenticationToken(user.get(), apiKey, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (AuthenticationException e) {
// If API key authentication fails, deny the request
@@ -110,19 +115,20 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
String method = request.getMethod();
String contextPath = request.getContextPath();
if ("GET".equalsIgnoreCase(method) && !requestURI.startsWith(contextPath + "/login")) {
if ("GET".equalsIgnoreCase(method) && !(contextPath + "/login").equals(requestURI)) {
response.sendRedirect(contextPath + "/login"); // redirect to the login page
return;
} else {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter()
.write(
"""
Authentication required. Please provide a X-API-KEY in request header.
This is found in Settings -> Account Settings -> API Key
Alternatively you can disable authentication if this is unexpected.
""");
"Authentication required. Please provide a X-API-KEY in request"
+ " header.\n"
+ "This is found in Settings -> Account Settings -> API Key\n"
+ "Alternatively you can disable authentication if this is"
+ " unexpected");
return;
}
return;
}
// Check if the authenticated user is disabled and invalidate their session if so
@@ -220,12 +226,11 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String uri = request.getRequestURI();
String contextPath = request.getContextPath();
String[] permitAllPatterns = {
contextPath + "/login",
contextPath + "/signup",
contextPath + "/register",
contextPath + "/error",
contextPath + "/images/",
@@ -242,7 +247,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
for (String pattern : permitAllPatterns) {
if (uri.startsWith(pattern)
|| uri.endsWith(".svg")
|| uri.endsWith(".mjs")
|| uri.endsWith(".png")
|| uri.endsWith(".ico")) {
return true;
@@ -2,8 +2,5 @@ package stirling.software.proprietary.security.model;
public enum AuthenticationType {
WEB,
@Deprecated(since = "1.0.2")
SSO,
OAUTH2,
SAML2
SSO
}
@@ -2,8 +2,6 @@ package stirling.software.proprietary.security.model;
import java.io.Serializable;
import org.springframework.security.core.GrantedAuthority;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
@@ -20,7 +18,7 @@ import lombok.Setter;
@Table(name = "authorities")
@Getter
@Setter
public class Authority implements GrantedAuthority, Serializable {
public class Authority implements Serializable {
private static final long serialVersionUID = 1L;
@@ -1,33 +0,0 @@
package stirling.software.proprietary.security.model;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@NoArgsConstructor
@ToString(onlyExplicitlyIncluded = true)
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class JwtVerificationKey implements Serializable {
@Serial private static final long serialVersionUID = 1L;
@ToString.Include private String keyId;
private String verifyingKey;
@ToString.Include private LocalDateTime createdAt;
public JwtVerificationKey(String keyId, String verifyingKey) {
this.keyId = keyId;
this.verifyingKey = verifyingKey;
this.createdAt = LocalDateTime.now();
}
}

Some files were not shown because too many files have changed in this diff Show More