mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Fix remaining CI checks and restore Spring profile, settings.yml and persist parity
This commit is contained in:
@@ -58,16 +58,18 @@ jobs:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
run: ./gradlew :stirling-pdf:bootJar -PnoSpotless --no-daemon
|
||||
run: ./gradlew :stirling-pdf:quarkusBuild -PnoSpotless --no-daemon
|
||||
|
||||
- name: Locate built JAR
|
||||
id: jar
|
||||
run: |
|
||||
jar=$(find app/core/build/libs -maxdepth 1 -name 'Stirling-PDF*.jar' -o -name 'stirling-pdf*.jar' 2>/dev/null \
|
||||
| grep -vE '(-plain|-sources)\.jar$' | head -n 1)
|
||||
# Quarkus (quarkus.package.jar.type=uber-jar) emits a standalone runnable
|
||||
# jar at app/core/build/<name>-runner.jar, replacing the Spring Boot bootJar
|
||||
# that used to land in app/core/build/libs.
|
||||
jar=$(find app/core/build -maxdepth 1 -name '*-runner.jar' 2>/dev/null | head -n 1)
|
||||
if [[ -z "$jar" ]]; then
|
||||
echo "::error::No JAR under app/core/build/libs"
|
||||
ls -lah app/core/build/libs || true
|
||||
echo "::error::No *-runner.jar under app/core/build"
|
||||
ls -lah app/core/build || true
|
||||
exit 1
|
||||
fi
|
||||
# Absolute path - the migration script pushd's into a temp workdir
|
||||
|
||||
@@ -16,7 +16,7 @@ repos:
|
||||
hooks:
|
||||
- id: codespell
|
||||
args:
|
||||
- --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment
|
||||
- --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment,vertx
|
||||
- --skip="./.*,*.csv,*.json,*.ambr"
|
||||
- --quiet-level=2
|
||||
files: \.(html|css|js|py|md)$
|
||||
|
||||
@@ -13,8 +13,7 @@ import java.util.stream.Stream;
|
||||
import org.eclipse.microprofile.config.Config;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import io.quarkus.arc.DefaultBean;
|
||||
import io.quarkus.arc.profile.UnlessBuildProfile;
|
||||
import io.quarkus.arc.profile.IfBuildProfile;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.enterprise.context.Dependent;
|
||||
@@ -318,29 +317,28 @@ public class AppConfig {
|
||||
return applicationProperties.getSystem().getDatasource();
|
||||
}
|
||||
|
||||
// @UnlessBuildProfile("saas"): in the saas flavor SaasLicenseOverride provides these @Named
|
||||
// beans (every tenant is ENTERPRISE). @DefaultBean alone is not enough - Qute's named-bean
|
||||
// validation still sees this @DefaultBean producer alongside the saas one and rejects the
|
||||
// duplicate @Named key, so this default must be vetoed outright under the saas profile.
|
||||
// @IfBuildProfile("core"): these NORMAL/default license @Named beans apply only to the core
|
||||
// flavor. In proprietary EEAppConfig provides them (security profile) and in saas
|
||||
// SaasLicenseOverride does (saas profile); registering this producer alongside those trips
|
||||
// Qute's named-bean validation ("Duplicate key runningEE"), which does not honour @DefaultBean
|
||||
// suppression - so gate to core outright. (In core, EEAppConfig/SaasLicenseOverride are not
|
||||
// even on the classpath.)
|
||||
@Produces
|
||||
@DefaultBean
|
||||
@UnlessBuildProfile("saas")
|
||||
@IfBuildProfile("core")
|
||||
@Named("runningProOrHigher")
|
||||
public boolean runningProOrHigher() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Produces
|
||||
@DefaultBean
|
||||
@UnlessBuildProfile("saas")
|
||||
@IfBuildProfile("core")
|
||||
@Named("runningEE")
|
||||
public boolean runningEnterprise() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Produces
|
||||
@DefaultBean
|
||||
@UnlessBuildProfile("saas")
|
||||
@IfBuildProfile("core")
|
||||
@Named("license")
|
||||
public String licenseType() {
|
||||
return "NORMAL";
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.microprofile.config.spi.ConfigSource;
|
||||
import org.snakeyaml.engine.v2.api.Load;
|
||||
import org.snakeyaml.engine.v2.api.LoadSettings;
|
||||
|
||||
/**
|
||||
* Exposes {@code settings.yml} (and {@code custom_settings.yml}, with the bundled {@code
|
||||
* settings.yml.template} as the default fallback) as a MicroProfile/SmallRye {@link ConfigSource}.
|
||||
*
|
||||
* <p>Restores the Spring {@code @ConfigurationProperties} behaviour that bound {@code settings.yml}
|
||||
* into {@code ApplicationProperties}: without this the YAML was never read under Quarkus, so flags
|
||||
* like {@code security.enableLogin} fell back to their Java defaults regardless of the file (the
|
||||
* {@code enableLogin=false}/{@code maxDPI=0}/{@code loginAttemptCount=0} class of bugs). The nested
|
||||
* YAML is flattened to dotted keys ({@code security.enableLogin -> "true"}); {@link
|
||||
* ApplicationPropertiesConfigOverlay} and {@code @ConfigProperty} injections then read them.
|
||||
*
|
||||
* <p>Ordinal {@value #ORDINAL} sits above {@code application.properties} (250) but below
|
||||
* environment variables (300) and system properties (400), matching Spring's precedence - e.g.
|
||||
* {@code SECURITY_ENABLELOGIN} still overrides the file.
|
||||
*
|
||||
* <p>Registered via {@code META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource}.
|
||||
*/
|
||||
public class SettingsYamlConfigSource implements ConfigSource {
|
||||
|
||||
private static final int ORDINAL = 275;
|
||||
|
||||
private final Map<String, String> properties;
|
||||
|
||||
public SettingsYamlConfigSource() {
|
||||
this.properties = load();
|
||||
}
|
||||
|
||||
private static Map<String, String> load() {
|
||||
Map<String, String> flat = new HashMap<>();
|
||||
// 1. Bundled template provides the defaults (e.g. security.enableLogin: true).
|
||||
try (InputStream in =
|
||||
SettingsYamlConfigSource.class
|
||||
.getClassLoader()
|
||||
.getResourceAsStream("settings.yml.template")) {
|
||||
if (in != null) {
|
||||
flatten("", loadYaml(in), flat);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// best effort - fall through to file overrides / Java defaults
|
||||
}
|
||||
// 2. The user's settings.yml overrides the template.
|
||||
mergeFile(InstallationPathConfig.getSettingsPath(), flat);
|
||||
// 3. custom_settings.yml overrides settings.yml.
|
||||
mergeFile(InstallationPathConfig.getCustomSettingsPath(), flat);
|
||||
return flat;
|
||||
}
|
||||
|
||||
private static void mergeFile(String path, Map<String, String> flat) {
|
||||
try {
|
||||
Path p = Path.of(path);
|
||||
if (Files.isRegularFile(p)) {
|
||||
try (InputStream in = Files.newInputStream(p)) {
|
||||
flatten("", loadYaml(in), flat);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// unreadable/invalid file - keep whatever defaults were already loaded
|
||||
}
|
||||
}
|
||||
|
||||
private static Object loadYaml(InputStream in) {
|
||||
return new Load(LoadSettings.builder().build()).loadFromInputStream(in);
|
||||
}
|
||||
|
||||
private static void flatten(String prefix, Object node, Map<String, String> out) {
|
||||
if (node instanceof Map<?, ?> map) {
|
||||
for (Map.Entry<?, ?> e : map.entrySet()) {
|
||||
String key =
|
||||
prefix.isEmpty() ? String.valueOf(e.getKey()) : prefix + "." + e.getKey();
|
||||
flatten(key, e.getValue(), out);
|
||||
}
|
||||
} else if (node instanceof List<?>) {
|
||||
// MicroProfile list binding uses indexed keys; the ApplicationProperties overlay reads
|
||||
// scalar values only, so skip lists rather than emit a malformed "[a, b]" value.
|
||||
return;
|
||||
} else if (node != null) {
|
||||
out.put(prefix, String.valueOf(node));
|
||||
}
|
||||
// null leaves are left unset so the Java default applies.
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> getProperties() {
|
||||
return properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getPropertyNames() {
|
||||
return properties.keySet();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getValue(String propertyName) {
|
||||
return properties.get(propertyName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "settings.yml";
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrdinal() {
|
||||
return ORDINAL;
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
stirling.software.common.configuration.SettingsYamlConfigSource
|
||||
@@ -341,8 +341,20 @@ if (buildWithFrontend) {
|
||||
// DatabaseService/NoOpDatabaseService both stay active (ambiguous). The profile is fixed at build
|
||||
// time by @IfBuildProfile, so this must be set before quarkusBuild runs; doing it at configuration
|
||||
// time (guarded by the flavor) keeps core/proprietary builds on the default profile.
|
||||
// Map the build flavor to a Quarkus feature profile, replacing Spring's runtime profile
|
||||
// auto-detection (former getActiveProfile() -> setAdditionalProfiles). @IfBuildProfile/@Unless
|
||||
// gate beans on these. Set as the profile PARENT (not the profile itself) for security/core so the
|
||||
// run-mode profile (prod for quarkusBuild, dev for quarkusDev) - and dev services/live reload -
|
||||
// stay intact; saas keeps using its own "saas" profile.
|
||||
// core -> "core" (default license beans only)
|
||||
// proprietary -> "security" (EEAppConfig: security & !saas)
|
||||
// saas -> "saas" (SaasLicenseOverride)
|
||||
if (gradle.ext.enableSaas) {
|
||||
System.setProperty('quarkus.profile', 'saas')
|
||||
} else if (!gradle.ext.disableAdditional) {
|
||||
System.setProperty('quarkus.config.profile.parent', 'security')
|
||||
} else {
|
||||
System.setProperty('quarkus.config.profile.parent', 'core')
|
||||
}
|
||||
|
||||
// The Quarkus build must see the library modules' jars (and their Jandex indexes) on the
|
||||
@@ -404,3 +416,56 @@ tasks.named("quarkusDev") {
|
||||
|
||||
jvmArgs = runtimeArgs
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compatibility aliases for the Spring Boot Gradle tasks removed in the Quarkus
|
||||
// migration. CI workflows, the Taskfile and desktop builds still invoke these
|
||||
// task names; each delegates to the Quarkus equivalent so those callers keep
|
||||
// working without a sweeping rename across the build scripts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// bootRun -> quarkusDev: run the application from source (used by the e2e and
|
||||
// dev tasks to boot a live :8080 server).
|
||||
tasks.register('bootRun') {
|
||||
group = 'application'
|
||||
description = 'Compatibility alias for the removed Spring Boot bootRun; runs quarkusDev.'
|
||||
dependsOn 'quarkusDev'
|
||||
}
|
||||
|
||||
// bootJar -> quarkusBuild: produce the runnable uber-jar at
|
||||
// app/core/build/<name>-runner.jar (quarkus.package.jar.type=uber-jar).
|
||||
tasks.register('bootJar') {
|
||||
group = 'build'
|
||||
description = 'Compatibility alias for the removed Spring Boot bootJar; runs quarkusBuild.'
|
||||
dependsOn 'quarkusBuild'
|
||||
}
|
||||
|
||||
// OpenAPI doc generation replacing springdoc's generateOpenApiDocs/copySwaggerDoc. quarkusBuild
|
||||
// exports the SmallRye schema (quarkus.smallrye-openapi.store-schema-directory) during augmentation;
|
||||
// copySwaggerDoc publishes that openapi.json to SwaggerDoc.json at the repo root, which the
|
||||
// check-openapi workflow and the ai-engine tool-models step consume.
|
||||
tasks.register('copySwaggerDoc') {
|
||||
group = 'documentation'
|
||||
description = 'Publish the SmallRye OpenAPI schema to SwaggerDoc.json at the repo root.'
|
||||
dependsOn 'quarkusBuild'
|
||||
doLast {
|
||||
def schema = fileTree(layout.buildDirectory)
|
||||
.matching { include '**/openapi-schema/openapi.json' }
|
||||
.files
|
||||
.find { it != null }
|
||||
if (schema == null) {
|
||||
throw new GradleException(
|
||||
"No openapi-schema/openapi.json found under ${layout.buildDirectory.get()}; " +
|
||||
"check quarkus.smallrye-openapi.store-schema-directory.")
|
||||
}
|
||||
def target = new File(rootProject.projectDir, 'SwaggerDoc.json')
|
||||
target.text = schema.text
|
||||
logger.lifecycle("Wrote OpenAPI schema (${schema.length()} bytes) to ${target}")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('generateOpenApiDocs') {
|
||||
group = 'documentation'
|
||||
description = 'Compatibility alias for the removed springdoc generateOpenApiDocs.'
|
||||
dependsOn 'copySwaggerDoc'
|
||||
}
|
||||
|
||||
@@ -95,6 +95,9 @@ quarkus.hibernate-orm.mapping.format.global=ignore
|
||||
quarkus.smallrye-openapi.path=/v1/api-docs
|
||||
quarkus.swagger-ui.path=/swagger-ui.html
|
||||
quarkus.swagger-ui.always-include=true
|
||||
# Export the schema at build time (during quarkusBuild) so the copySwaggerDoc Gradle task can
|
||||
# publish it as SwaggerDoc.json - the build-time replacement for springdoc's generateOpenApiDocs.
|
||||
quarkus.smallrye-openapi.store-schema-directory=build/openapi-schema
|
||||
|
||||
# ---- Jackson (was spring.jackson.*) ----------------------------------------------------------
|
||||
# spring.jackson.deserialization.fail-on-null-for-primitives=false
|
||||
|
||||
-314
@@ -1,314 +0,0 @@
|
||||
package stirling.software.proprietary.security;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import com.coveo.saml.SamlClient;
|
||||
import com.coveo.saml.SamlException;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
|
||||
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
|
||||
import stirling.software.common.model.io.Resource;
|
||||
import stirling.software.common.model.oauth2.KeycloakProvider;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
import stirling.software.common.util.UrlUtils;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
import stirling.software.proprietary.security.saml2.CertificateUtils;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.service.AiUserDataService;
|
||||
|
||||
// TODO: Migration required - this class was a Spring Security
|
||||
// SimpleUrlLogoutSuccessHandler wired into the Spring Security logout filter chain.
|
||||
// Quarkus has no LogoutSuccessHandler equivalent. The logout endpoint must be rehosted
|
||||
// (e.g. a JAX-RS resource or jakarta.servlet endpoint) that invokes onLogoutSuccess(...)
|
||||
// after the Quarkus security/session logout has run. Configure HTTP auth/logout policies
|
||||
// via quarkus.http.auth.* and quarkus-oidc (for OAuth2/OIDC logout).
|
||||
@Slf4j
|
||||
@ApplicationScoped
|
||||
@RequiredArgsConstructor(onConstructor_ = {@Inject})
|
||||
public class CustomLogoutSuccessHandler {
|
||||
|
||||
public static final String LOGOUT_PATH = "/login?logout=true";
|
||||
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
|
||||
private final AppConfig appConfig;
|
||||
|
||||
private final JwtServiceInterface jwtService;
|
||||
|
||||
private final AiUserDataService aiUserDataService;
|
||||
|
||||
// TODO: Migration required - Spring's AuthenticationTrustResolver
|
||||
// (used to filter out the anonymous principal) has no direct Quarkus equivalent.
|
||||
// Under Quarkus, an unauthenticated request yields an anonymous SecurityIdentity
|
||||
// (SecurityIdentity#isAnonymous()); use that check in resolveUsername(...) instead.
|
||||
|
||||
@Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC)
|
||||
public void onLogoutSuccess(
|
||||
HttpServletRequest request, HttpServletResponse response, Object authentication)
|
||||
throws IOException {
|
||||
|
||||
String username = resolveUsername(request, authentication);
|
||||
if (username != null) {
|
||||
aiUserDataService.purgeUserDocuments(username);
|
||||
}
|
||||
|
||||
if (!response.isCommitted()) {
|
||||
if (authentication != null) {
|
||||
// TODO: Migration required - the original code branched on the Spring
|
||||
// Authentication implementation type to choose a logout redirect:
|
||||
// Saml2Authentication -> getRedirect_saml2(...)
|
||||
// OAuth2AuthenticationToken -> getRedirect_oauth2(...)
|
||||
// UsernamePasswordAuthentication -> redirect to LOGOUT_PATH
|
||||
// unknown -> log + redirect to LOGOUT_PATH
|
||||
// Under Quarkus the authentication mechanism is identified differently
|
||||
// (SecurityIdentity attributes / quarkus-oidc vs form auth, or the IdP
|
||||
// recorded at login). Re-wire this dispatch to invoke getRedirect_saml2 /
|
||||
// getRedirect_oauth2 once the Quarkus identity model is in place. Until
|
||||
// then we fall through to the default login-page redirect to preserve
|
||||
// safe behavior (a single redirect, never IdP logout with a null subject).
|
||||
response.sendRedirect(LOGOUT_PATH);
|
||||
} else {
|
||||
if (jwtService != null) {
|
||||
String token = jwtService.extractToken(request);
|
||||
if (token != null && !token.isBlank()) {
|
||||
response.sendRedirect(LOGOUT_PATH);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Redirect to login page after logout
|
||||
String path = checkForErrors(request);
|
||||
response.sendRedirect(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the right name to purge under. JWT cookie wins if present and parseable; we fall through
|
||||
* to whatever the authentication handed us only when there's no cookie. The anonymous principal
|
||||
* is filtered out so we don't purge under that pseudo-user.
|
||||
*/
|
||||
private String resolveUsername(HttpServletRequest request, Object authentication) {
|
||||
if (jwtService != null) {
|
||||
String fromCookie = jwtService.extractUsernameFromRequestAllowExpired(request);
|
||||
if (fromCookie != null) {
|
||||
return fromCookie;
|
||||
}
|
||||
}
|
||||
// TODO: Migration required - replace the Spring AuthenticationTrustResolver
|
||||
// anonymous check and Authentication#getName() with SecurityIdentity:
|
||||
// if (identity == null || identity.isAnonymous()) return null;
|
||||
// String name = identity.getPrincipal().getName();
|
||||
if (authentication == null) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Redirect for SAML2 authentication logout
|
||||
// TODO: Migration required - parameter was Spring Saml2Authentication; the SAML2
|
||||
// principal (CustomSaml2AuthenticatedPrincipal) must be recovered from the Quarkus
|
||||
// identity once the SAML SP is rehosted on OpenSAML 5 (see SAML2 migration plan).
|
||||
private void getRedirect_saml2(
|
||||
HttpServletRequest request, HttpServletResponse response, Object samlAuthentication)
|
||||
throws IOException {
|
||||
|
||||
SAML2 samlConf = securityProperties.getSaml2();
|
||||
String registrationId = samlConf.getRegistrationId();
|
||||
|
||||
// TODO: Migration required - extract the SAML NameID from the Quarkus identity.
|
||||
// Original:
|
||||
// CustomSaml2AuthenticatedPrincipal principal =
|
||||
// (CustomSaml2AuthenticatedPrincipal) samlAuthentication.getPrincipal();
|
||||
// String nameIdValue = principal.name();
|
||||
String nameIdValue = null;
|
||||
|
||||
try {
|
||||
// Read certificate from the resource
|
||||
Resource certificateResource = samlConf.getSpCert();
|
||||
// TODO: Migration required - CertificateUtils still declares
|
||||
// org.springframework.core.io.Resource parameters; once it is migrated to
|
||||
// stirling.software.common.model.io.Resource these calls compile directly.
|
||||
X509Certificate certificate = CertificateUtils.readCertificate(certificateResource);
|
||||
|
||||
List<X509Certificate> certificates = new ArrayList<>();
|
||||
certificates.add(certificate);
|
||||
|
||||
// Construct URLs required for SAML configuration
|
||||
SamlClient samlClient = getSamlClient(registrationId, samlConf, certificates);
|
||||
|
||||
// Read private key for service provider
|
||||
Resource privateKeyResource = samlConf.getPrivateKey();
|
||||
RSAPrivateKey privateKey = CertificateUtils.readPrivateKey(privateKeyResource);
|
||||
|
||||
// Set service provider keys for the SamlClient
|
||||
samlClient.setSPKeys(certificate, privateKey);
|
||||
|
||||
// Build relay state to return user to login page after IdP logout
|
||||
String relayState =
|
||||
UrlUtils.getOrigin(request) + request.getContextPath() + LOGOUT_PATH;
|
||||
|
||||
// Redirect to identity provider for logout with relay state
|
||||
samlClient.redirectToIdentityProvider(response, relayState, nameIdValue);
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Error retrieving logout URL from Provider {} for user {}",
|
||||
samlConf.getProvider(),
|
||||
nameIdValue,
|
||||
e);
|
||||
response.sendRedirect(LOGOUT_PATH);
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect for OAuth2 authentication logout
|
||||
// TODO: Migration required - parameter was Spring OAuth2AuthenticationToken; under
|
||||
// quarkus-oidc the authorized client registration id must be obtained from the OIDC
|
||||
// configuration / SecurityIdentity rather than the token.
|
||||
private void getRedirect_oauth2(
|
||||
HttpServletRequest request, HttpServletResponse response, Object oAuthToken)
|
||||
throws IOException {
|
||||
String registrationId;
|
||||
OAUTH2 oauth = securityProperties.getOauth2();
|
||||
String path = checkForErrors(request);
|
||||
|
||||
String redirectUrl = UrlUtils.getOrigin(request) + "/login?" + path;
|
||||
// TODO: Migration required - original:
|
||||
// registrationId = oAuthToken.getAuthorizedClientRegistrationId();
|
||||
// Resolve the OIDC provider id from quarkus-oidc config / SecurityIdentity instead.
|
||||
registrationId = "";
|
||||
|
||||
// Redirect based on OAuth2 provider
|
||||
switch (registrationId.toLowerCase(Locale.ROOT)) {
|
||||
case "keycloak" -> {
|
||||
KeycloakProvider keycloak = oauth.getClient().getKeycloak();
|
||||
|
||||
boolean isKeycloak = !keycloak.getIssuer().isBlank();
|
||||
boolean isCustomOAuth = !oauth.getIssuer().isBlank();
|
||||
|
||||
String logoutUrl = redirectUrl;
|
||||
|
||||
if (isKeycloak) {
|
||||
logoutUrl = keycloak.getIssuer();
|
||||
} else if (isCustomOAuth) {
|
||||
logoutUrl = oauth.getIssuer();
|
||||
}
|
||||
if (isKeycloak || isCustomOAuth) {
|
||||
logoutUrl +=
|
||||
"/protocol/openid-connect/logout"
|
||||
+ "?client_id="
|
||||
+ oauth.getClientId()
|
||||
+ "&post_logout_redirect_uri="
|
||||
+ response.encodeRedirectURL(redirectUrl);
|
||||
log.info("Redirecting to Keycloak logout URL: {}", logoutUrl);
|
||||
} else {
|
||||
log.info(
|
||||
"No redirect URL for {} available. Redirecting to default logout URL:"
|
||||
+ " {}",
|
||||
registrationId,
|
||||
logoutUrl);
|
||||
}
|
||||
response.sendRedirect(logoutUrl);
|
||||
}
|
||||
case "github", "google" -> {
|
||||
log.info(
|
||||
"No redirect URL for {} available. Redirecting to default logout URL: {}",
|
||||
registrationId,
|
||||
redirectUrl);
|
||||
response.sendRedirect(redirectUrl);
|
||||
}
|
||||
default -> {
|
||||
log.info("Redirecting to default logout URL: {}", redirectUrl);
|
||||
response.sendRedirect(redirectUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SamlClient getSamlClient(
|
||||
String registrationId, SAML2 samlConf, List<X509Certificate> certificates)
|
||||
throws SamlException {
|
||||
String serverUrl = appConfig.getBackendUrl() + ":" + appConfig.getServerPort();
|
||||
|
||||
String relyingPartyIdentifier =
|
||||
serverUrl + "/saml2/service-provider-metadata/" + registrationId;
|
||||
|
||||
String assertionConsumerServiceUrl = serverUrl + "/login/saml2/sso/" + registrationId;
|
||||
|
||||
String idpSLOUrl = samlConf.getIdpSingleLogoutUrl();
|
||||
|
||||
String idpIssuer = samlConf.getIdpIssuer();
|
||||
|
||||
// Create SamlClient instance for SAML logout
|
||||
return new SamlClient(
|
||||
relyingPartyIdentifier,
|
||||
assertionConsumerServiceUrl,
|
||||
idpSLOUrl,
|
||||
idpIssuer,
|
||||
certificates,
|
||||
SamlClient.SamlIdpBinding.POST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles different error scenarios during logout. Will return a <code>String</code> containing
|
||||
* the error request parameter.
|
||||
*
|
||||
* @param request the user's <code>HttpServletRequest</code> request.
|
||||
* @return a <code>String</code> containing the error request parameter.
|
||||
*/
|
||||
private String checkForErrors(HttpServletRequest request) {
|
||||
String errorMessage;
|
||||
String path = "logout=true";
|
||||
|
||||
if (request.getParameter("oAuth2AuthenticationErrorWeb") != null) {
|
||||
path = "errorOAuth=userAlreadyExistsWeb";
|
||||
} else if ((errorMessage = request.getParameter("errorOAuth")) != null) {
|
||||
path = "errorOAuth=" + sanitizeInput(errorMessage);
|
||||
} else if (request.getParameter("oAuth2AutoCreateDisabled") != null) {
|
||||
path = "errorOAuth=oAuth2AutoCreateDisabled";
|
||||
} else if (request.getParameter("oAuth2AdminBlockedUser") != null) {
|
||||
path = "errorOAuth=oAuth2AdminBlockedUser";
|
||||
} else if (request.getParameter("oAuth2RequiresLicense") != null) {
|
||||
path = "errorOAuth=oAuth2RequiresLicense";
|
||||
} else if (request.getParameter("saml2RequiresLicense") != null) {
|
||||
path = "errorOAuth=saml2RequiresLicense";
|
||||
} else if (request.getParameter("maxUsersReached") != null) {
|
||||
path = "errorOAuth=maxUsersReached";
|
||||
} else if (request.getParameter("userIsDisabled") != null) {
|
||||
path = "errorOAuth=userIsDisabled";
|
||||
} else if ((errorMessage = request.getParameter("error")) != null) {
|
||||
path = "errorOAuth=" + sanitizeInput(errorMessage);
|
||||
} else if (request.getParameter("badCredentials") != null) {
|
||||
path = "errorOAuth=badCredentials";
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize input to avoid potential security vulnerabilities. Will return a sanitised <code>
|
||||
* String</code>.
|
||||
*
|
||||
* @return a sanitised <code>String</code>
|
||||
*/
|
||||
private String sanitizeInput(String input) {
|
||||
return RegexPatternUtils.getInstance()
|
||||
.getInputSanitizePattern()
|
||||
.matcher(input)
|
||||
.replaceAll("");
|
||||
}
|
||||
}
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
|
||||
// TODO: Migration required - this class previously extended Spring Security's
|
||||
// SimpleUrlAuthenticationFailureHandler and was wired into the OAuth2 login flow as the
|
||||
// failure handler. quarkus-oidc has no direct equivalent for a servlet
|
||||
// AuthenticationFailureHandler. Under quarkus-oidc the failure path should be handled via
|
||||
// quarkus.oidc.* config (e.g. quarkus.oidc.authentication.error-path) plus a
|
||||
// jakarta.ws.rs.ext.ExceptionMapper / SecurityIdentityAugmentor or a redirect filter that
|
||||
// inspects the OIDC error and applies the same redirect logic below. The redirect-building
|
||||
// logic (Tauri handling, cookie clearing, query-param construction) is preserved here as a
|
||||
// reusable bean; rewire the actual failure dispatch to call onAuthenticationFailure(...) once
|
||||
// the quarkus-oidc wiring is in place. The original Spring exception types
|
||||
// (BadCredentialsException / DisabledException / LockedException / OAuth2AuthenticationException
|
||||
// + OAuth2Error) were used to branch on the failure cause and must be re-mapped to the
|
||||
// quarkus-oidc equivalents (io.quarkus.oidc / io.quarkus.security exceptions).
|
||||
@Slf4j
|
||||
@ApplicationScoped
|
||||
public class CustomOAuth2AuthenticationFailureHandler {
|
||||
|
||||
@Audited(type = AuditEventType.USER_FAILED_LOGIN, level = AuditLevel.BASIC)
|
||||
public void onAuthenticationFailure(
|
||||
HttpServletRequest request, HttpServletResponse response, Exception exception)
|
||||
throws IOException, ServletException {
|
||||
|
||||
// TODO: Migration required - the original handler branched on Spring Security exception
|
||||
// types to choose a redirect target:
|
||||
// BadCredentialsException -> "/login?error=badCredentials"
|
||||
// DisabledException -> "/logout?userIsDisabled=true"
|
||||
// LockedException -> "/logout?error=locked"
|
||||
// OAuth2AuthenticationException (with OAuth2Error.getErrorCode()) -> OAuth2 error flow
|
||||
// Re-map these branches to the corresponding quarkus-oidc / io.quarkus.security failure
|
||||
// causes. The OAuth2 error-code handling below is preserved but the error code can no
|
||||
// longer be extracted from OAuth2Error and must be sourced from the OIDC failure context.
|
||||
|
||||
String errorCode = null;
|
||||
if ("Password must not be null".equals(errorCode)) {
|
||||
errorCode = "userAlreadyExistsWeb";
|
||||
}
|
||||
|
||||
log.error(
|
||||
"OAuth2 Authentication error: {}",
|
||||
errorCode != null ? errorCode : exception.getMessage(),
|
||||
exception);
|
||||
String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError";
|
||||
clearRedirectCookie(response);
|
||||
boolean tauriState = TauriOAuthUtils.isTauriState(request);
|
||||
String redirectUrl;
|
||||
if (tauriState) {
|
||||
String basePath = TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath());
|
||||
redirectUrl = basePath;
|
||||
String stateParam = request.getParameter("state");
|
||||
if (stateParam != null && !stateParam.isBlank()) {
|
||||
redirectUrl = appendQueryParam(redirectUrl, "state", stateParam);
|
||||
// Extract and pass nonce for CSRF validation
|
||||
String nonce = TauriOAuthUtils.extractNonceFromState(stateParam);
|
||||
if (nonce != null) {
|
||||
redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce);
|
||||
}
|
||||
}
|
||||
redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue);
|
||||
} else {
|
||||
redirectUrl = buildFailureRedirectUrl(request, errorValue);
|
||||
}
|
||||
response.sendRedirect(redirectUrl);
|
||||
}
|
||||
|
||||
private String buildFailureRedirectUrl(HttpServletRequest request, String errorValue) {
|
||||
String contextPath = request.getContextPath();
|
||||
String cookiePath = TauriOAuthUtils.extractRedirectPathFromCookie(request);
|
||||
String redirectPath =
|
||||
cookiePath != null ? cookiePath : TauriOAuthUtils.defaultCallbackPath(contextPath);
|
||||
if (TauriOAuthUtils.isTauriState(request)) {
|
||||
redirectPath = appendQueryParam(redirectPath, "tauri", "1");
|
||||
}
|
||||
String resolvedPath =
|
||||
redirectPath.startsWith("/")
|
||||
? TauriOAuthUtils.normalizeContextPath(contextPath) + redirectPath
|
||||
: TauriOAuthUtils.normalizeContextPath(contextPath) + "/" + redirectPath;
|
||||
return appendQueryParam(resolvedPath, "errorOAuth", errorValue);
|
||||
}
|
||||
|
||||
private void clearRedirectCookie(HttpServletResponse response) {
|
||||
// Replaces Spring's ResponseCookie/HttpHeaders.SET_COOKIE with a plain servlet header.
|
||||
String cookie = TauriOAuthUtils.SPA_REDIRECT_COOKIE + "=; Path=/; Max-Age=0; SameSite=Lax";
|
||||
response.addHeader("Set-Cookie", cookie);
|
||||
}
|
||||
|
||||
private String appendQueryParam(String path, String key, String value) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return path;
|
||||
}
|
||||
String separator = path.contains("?") ? "&" : "?";
|
||||
String encodedKey = URLEncoder.encode(key, StandardCharsets.UTF_8);
|
||||
String encodedValue = value == null ? "" : URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
return path + separator + encodedKey + "=" + encodedValue;
|
||||
}
|
||||
}
|
||||
-107
@@ -1,107 +0,0 @@
|
||||
package stirling.software.proprietary.security.saml2;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
import stirling.software.proprietary.security.oauth2.TauriOAuthUtils;
|
||||
|
||||
// TODO: Migration required - This was a Spring Security SimpleUrlAuthenticationFailureHandler
|
||||
// (@ConditionalOnProperty "security.saml2.enabled"). There is NO Quarkus SAML extension, so the
|
||||
// SAML SP must be rehosted on a Jakarta @WebServlet using OpenSAML 5 (dnulnets/quarkus-saml
|
||||
// pattern). When that servlet is in place, wire it to invoke onAuthenticationFailure(...) below
|
||||
// on SAML authentication failures. The Spring security glue removed:
|
||||
// - extends SimpleUrlAuthenticationFailureHandler / getRedirectStrategy().sendRedirect(...)
|
||||
// -> replaced by HttpServletResponse.sendRedirect(...)
|
||||
// - org.springframework.security.core.AuthenticationException
|
||||
// -> replaced by a generic Exception parameter
|
||||
// - org.springframework.security.saml2.{Saml2Error, Saml2AuthenticationException}
|
||||
// -> re-derive the SAML error code from the OpenSAML 5 failure handling in the new servlet
|
||||
// - org.springframework.security.authentication.ProviderNotFoundException
|
||||
// -> map to the "not_authentication_provider_found" branch from the new servlet
|
||||
// TODO: Migration required - the @ConditionalOnProperty(name = "security.saml2.enabled",
|
||||
// havingValue = "true") build-time toggle was removed; this is a runtime property, so guard
|
||||
// invocation at the call site (the SAML servlet) or via a runtime config check rather than a
|
||||
// CDI/build-profile condition.
|
||||
@Slf4j
|
||||
@ApplicationScoped
|
||||
public class CustomSaml2AuthenticationFailureHandler {
|
||||
|
||||
@Audited(type = AuditEventType.USER_FAILED_LOGIN, level = AuditLevel.BASIC)
|
||||
public void onAuthenticationFailure(
|
||||
HttpServletRequest request, HttpServletResponse response, Exception exception)
|
||||
throws IOException {
|
||||
log.error("Authentication error", exception);
|
||||
|
||||
// TODO: Migration required - the original branched on Spring's
|
||||
// Saml2AuthenticationException (extracting Saml2Error.getErrorCode()) vs
|
||||
// ProviderNotFoundException. With OpenSAML 5 on a Jakarta servlet, derive the SAML
|
||||
// error code and the "no provider found" condition from the new failure-handling code
|
||||
// and call the corresponding branch below.
|
||||
String samlErrorCode = resolveSamlErrorCode(exception);
|
||||
if (samlErrorCode != null) {
|
||||
if (TauriSamlUtils.isTauriRelayState(request)) {
|
||||
String redirectUrl =
|
||||
TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath());
|
||||
String nonce = TauriSamlUtils.extractNonceFromRequest(request);
|
||||
if (nonce != null) {
|
||||
redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce);
|
||||
}
|
||||
redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", samlErrorCode);
|
||||
response.sendRedirect(redirectUrl);
|
||||
return;
|
||||
}
|
||||
response.sendRedirect("/login?errorOAuth=" + samlErrorCode);
|
||||
} else if (isProviderNotFound(exception)) {
|
||||
if (TauriSamlUtils.isTauriRelayState(request)) {
|
||||
String redirectUrl =
|
||||
TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath());
|
||||
String nonce = TauriSamlUtils.extractNonceFromRequest(request);
|
||||
if (nonce != null) {
|
||||
redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce);
|
||||
}
|
||||
redirectUrl =
|
||||
appendQueryParam(
|
||||
redirectUrl, "errorOAuth", "not_authentication_provider_found");
|
||||
response.sendRedirect(redirectUrl);
|
||||
return;
|
||||
}
|
||||
response.sendRedirect("/login?errorOAuth=not_authentication_provider_found");
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Migration required - return the SAML error code when the failure originates from an
|
||||
// OpenSAML 5 SAML response error, otherwise null. Previously this came from
|
||||
// Saml2AuthenticationException.getSaml2Error().getErrorCode().
|
||||
private String resolveSamlErrorCode(Exception exception) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: Migration required - return true when no authentication provider was found.
|
||||
// Previously this was (exception instanceof ProviderNotFoundException).
|
||||
private boolean isProviderNotFound(Exception exception) {
|
||||
return false;
|
||||
}
|
||||
|
||||
private String appendQueryParam(String path, String key, String value) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return path;
|
||||
}
|
||||
String separator = path.contains("?") ? "&" : "?";
|
||||
String encodedKey =
|
||||
java.net.URLEncoder.encode(key, java.nio.charset.StandardCharsets.UTF_8);
|
||||
String encodedValue =
|
||||
value == null
|
||||
? ""
|
||||
: java.net.URLEncoder.encode(
|
||||
value, java.nio.charset.StandardCharsets.UTF_8);
|
||||
return path + separator + encodedKey + "=" + encodedValue;
|
||||
}
|
||||
}
|
||||
-431
@@ -1,431 +0,0 @@
|
||||
package stirling.software.proprietary.security.saml2;
|
||||
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.SAML2;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
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.oauth2.TauriOAuthUtils;
|
||||
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.util.DesktopClientUtils;
|
||||
|
||||
// TODO: Migration required - this class is Spring Security SAML2 success-handler glue and has no
|
||||
// Quarkus equivalent. There is no Quarkus SAML extension; the SAML SP flow must be rehosted on a
|
||||
// Jakarta @WebServlet using OpenSAML 5 (dnulnets/quarkus-saml pattern). The OpenSAML/business logic
|
||||
// below (eligibility checks, SSO post-login, JWT issuance, context-aware redirect building) is
|
||||
// preserved unchanged. The following Spring types were removed and need a Quarkus home:
|
||||
// - extends SavedRequestAwareAuthenticationSuccessHandler: the "saved request" replay behavior
|
||||
// (SPRING_SECURITY_SAVED_REQUEST session attribute + super.onAuthenticationSuccess) has no
|
||||
// direct Quarkus analogue; reimplement original-destination replay in the new SAML SP servlet.
|
||||
// - org.springframework.security.core.Authentication: the principal/authentication is now passed
|
||||
// as Object so the OpenSAML principal can still be unwrapped via
|
||||
// CustomSaml2AuthenticatedPrincipal.
|
||||
// - org.springframework.security.authentication.LockedException: replaced by a plain
|
||||
// IllegalStateException to signal a locked account; the new SP must map this to a redirect.
|
||||
// - org.springframework.http.ResponseCookie / HttpHeaders: replaced with jakarta.servlet.Cookie.
|
||||
// Also: JwtServiceInterface.generateToken(Authentication, claims) (collaborator) still takes a
|
||||
// Spring
|
||||
// Authentication; once that interface is migrated, restore the web-path token call that used it.
|
||||
@AllArgsConstructor
|
||||
@Slf4j
|
||||
@ApplicationScoped
|
||||
public class CustomSaml2AuthenticationSuccessHandler {
|
||||
|
||||
private static final String SPA_REDIRECT_COOKIE = "stirling_redirect_path";
|
||||
private static final String DEFAULT_CALLBACK_PATH = "/auth/callback";
|
||||
|
||||
private LoginAttemptService loginAttemptService;
|
||||
private ApplicationProperties.Security.SAML2 saml2Properties;
|
||||
private UserService userService;
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
|
||||
public void onAuthenticationSuccess(
|
||||
HttpServletRequest request, HttpServletResponse response, Object authentication)
|
||||
throws ServletException, IOException {
|
||||
|
||||
// TODO: Migration required - previously obtained via Authentication.getPrincipal(). The new
|
||||
// SAML SP servlet must supply the OpenSAML principal (or the principal directly) here.
|
||||
Object principal = authentication;
|
||||
log.debug("Starting SAML2 authentication success handling");
|
||||
|
||||
if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2Principal) {
|
||||
String username = saml2Principal.name();
|
||||
log.debug("Authenticated principal found for user: {}", username);
|
||||
|
||||
boolean userExists = userService.usernameExistsIgnoreCase(username);
|
||||
|
||||
// Check if user is eligible for SAML (grandfathered or system has ENTERPRISE license)
|
||||
if (userExists) {
|
||||
stirling.software.proprietary.security.model.User user =
|
||||
userService.findByUsernameIgnoreCase(username).orElse(null);
|
||||
|
||||
if (user != null && !licenseSettingsService.isSamlEligible(user)) {
|
||||
// User is not grandfathered and no ENTERPRISE license - block SAML login
|
||||
log.warn(
|
||||
"SAML2 login blocked for existing user '{}' - not eligible (not grandfathered and no ENTERPRISE license)",
|
||||
username);
|
||||
String origin = resolveOrigin(request);
|
||||
response.sendRedirect(origin + "/logout?saml2RequiresLicense=true");
|
||||
return;
|
||||
}
|
||||
} else if (!licenseSettingsService.isSamlEligible(null)) {
|
||||
// No existing user and no ENTERPRISE license -> block auto creation
|
||||
log.warn(
|
||||
"SAML2 login blocked for new user '{}' - not eligible (no ENTERPRISE license for auto-creation)",
|
||||
username);
|
||||
String origin = resolveOrigin(request);
|
||||
response.sendRedirect(origin + "/logout?saml2RequiresLicense=true");
|
||||
return;
|
||||
}
|
||||
|
||||
HttpSession session = request.getSession(false);
|
||||
String contextPath = request.getContextPath();
|
||||
// TODO: Migration required - SPRING_SECURITY_SAVED_REQUEST was Spring Security's
|
||||
// SavedRequest stored on the session. Quarkus has no SavedRequest type; the new SAML SP
|
||||
// must persist and replay the original destination itself. Treated as absent for now.
|
||||
Object savedRequest = null;
|
||||
|
||||
log.debug(
|
||||
"Session exists: {}, Saved request exists: {}",
|
||||
session != null,
|
||||
savedRequest != null);
|
||||
|
||||
if (savedRequest != null) {
|
||||
// TODO: Migration required - original-destination replay previously delegated to
|
||||
// super.onAuthenticationSuccess(...)
|
||||
// (SavedRequestAwareAuthenticationSuccessHandler).
|
||||
// Reimplement saved-request redirect in the new SAML SP servlet, guarding static
|
||||
// resources via RequestUriUtils.isStaticResource(contextPath, savedRedirectUrl).
|
||||
log.debug("Saved request handling pending SAML SP migration");
|
||||
} else {
|
||||
log.debug(
|
||||
"Processing SAML2 authentication with autoCreateUser: {}",
|
||||
saml2Properties.getAutoCreateUser());
|
||||
|
||||
if (loginAttemptService.isBlocked(username)) {
|
||||
log.debug("User {} is blocked due to too many login attempts", username);
|
||||
if (session != null) {
|
||||
session.removeAttribute("SPRING_SECURITY_SAVED_REQUEST");
|
||||
}
|
||||
// TODO: Migration required - was org.springframework.security.authentication
|
||||
// .LockedException; the new SAML SP must translate this into a locked-account
|
||||
// redirect/response.
|
||||
throw new IllegalStateException(
|
||||
"Your account has been locked due to too many failed login attempts.");
|
||||
}
|
||||
|
||||
boolean hasPassword = userExists && userService.hasPassword(username);
|
||||
boolean isSsoUser =
|
||||
userExists && userService.isSsoAuthenticationTypeByUsername(username);
|
||||
boolean isSAML2User =
|
||||
userExists && userService.isAuthenticationTypeByUsername(username, SAML2);
|
||||
|
||||
log.debug(
|
||||
"User status - Exists: {}, Has password: {}, Is SSO user: {}, Is SAML2 user: {}",
|
||||
userExists,
|
||||
hasPassword,
|
||||
isSsoUser,
|
||||
isSAML2User);
|
||||
|
||||
if (userExists
|
||||
&& hasPassword
|
||||
&& !isSsoUser
|
||||
&& saml2Properties.getAutoCreateUser()) {
|
||||
log.debug(
|
||||
"User {} exists with password but is not an SSO user, redirecting to logout",
|
||||
username);
|
||||
String origin = resolveOrigin(request);
|
||||
response.sendRedirect(origin + "/logout?oAuth2AuthenticationErrorWeb=true");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Block new users only if: blockRegistration is true OR autoCreateUser is false
|
||||
if (!userExists
|
||||
&& (saml2Properties.getBlockRegistration()
|
||||
|| !saml2Properties.getAutoCreateUser())) {
|
||||
log.debug(
|
||||
"Registration blocked for new user '{}' (blockRegistration: {}, autoCreateUser: {})",
|
||||
username,
|
||||
saml2Properties.getBlockRegistration(),
|
||||
saml2Properties.getAutoCreateUser());
|
||||
String origin = resolveOrigin(request);
|
||||
response.sendRedirect(origin + "/login?errorOAuth=oAuth2AdminBlockedUser");
|
||||
return;
|
||||
}
|
||||
if (!userExists && licenseSettingsService.wouldExceedLimit(1)) {
|
||||
String origin = resolveOrigin(request);
|
||||
response.sendRedirect(origin + "/logout?maxUsersReached=true");
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract SSO provider information from SAML2 assertion
|
||||
String ssoProviderId = saml2Principal.nameId();
|
||||
String ssoProvider = "saml2"; // fixme
|
||||
|
||||
log.debug(
|
||||
"Processing SSO post-login for user: {} (Provider: {}, ProviderId: {})",
|
||||
username,
|
||||
ssoProvider,
|
||||
ssoProviderId);
|
||||
|
||||
userService.processSSOPostLogin(
|
||||
username,
|
||||
ssoProviderId,
|
||||
ssoProvider,
|
||||
saml2Properties.getAutoCreateUser(),
|
||||
SAML2);
|
||||
log.debug("Successfully processed authentication for user: {}", username);
|
||||
|
||||
// Generate JWT if v2 is enabled
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
Map<String, Object> claims = Map.of("authType", AuthenticationType.SAML2);
|
||||
|
||||
// Detect desktop client and issue longer-lived tokens
|
||||
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request);
|
||||
String jwt;
|
||||
if (isDesktopClient) {
|
||||
// Desktop: Use configured desktop token expiry (default 30 days)
|
||||
int desktopExpiryMinutes =
|
||||
DesktopClientUtils.getDesktopTokenExpiryMinutes(
|
||||
applicationProperties);
|
||||
jwt = jwtService.generateToken(username, claims, desktopExpiryMinutes);
|
||||
log.info(
|
||||
"Issued DESKTOP SAML token for user '{}': expiry={}min ({}d)",
|
||||
username,
|
||||
desktopExpiryMinutes,
|
||||
desktopExpiryMinutes / 1440);
|
||||
} else {
|
||||
// Web: Use default expiry.
|
||||
// TODO: Migration required - originally
|
||||
// jwtService.generateToken(authentication, claims) using the Spring
|
||||
// Authentication. Switched to the username overload until
|
||||
// JwtServiceInterface drops its Spring Authentication parameter.
|
||||
jwt = jwtService.generateToken(username, claims);
|
||||
log.debug("Issued WEB SAML token for user '{}'", username);
|
||||
}
|
||||
|
||||
// Build context-aware redirect URL based on the original request
|
||||
String redirectUrl =
|
||||
buildContextAwareRedirectUrl(request, response, contextPath, jwt);
|
||||
|
||||
response.sendRedirect(redirectUrl);
|
||||
} else {
|
||||
// v1: redirect directly to home
|
||||
response.sendRedirect(contextPath + "/");
|
||||
}
|
||||
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
|
||||
log.debug(
|
||||
"Invalid username detected for user: {}, redirecting to logout",
|
||||
username);
|
||||
response.sendRedirect(contextPath + "/logout?invalidUsername=true");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO: Migration required - non-SAML2 principals were delegated to the Spring base
|
||||
// SavedRequestAwareAuthenticationSuccessHandler. The new SAML SP servlet must decide
|
||||
// how
|
||||
// to handle non-SAML2 principals (this handler should only receive SAML2 ones).
|
||||
log.debug("Non-SAML2 principal detected, no parent handler available after migration");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a context-aware redirect URL based on the request's origin
|
||||
*
|
||||
* @param request The HTTP request
|
||||
* @param contextPath The application context path
|
||||
* @param jwt The JWT token to include
|
||||
* @return The appropriate redirect URL
|
||||
*/
|
||||
private String buildContextAwareRedirectUrl(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
String contextPath,
|
||||
String jwt) {
|
||||
String redirectPath = resolveRedirectPath(request, contextPath);
|
||||
String origin = resolveOrigin(request);
|
||||
clearRedirectCookie(response);
|
||||
String url = origin + redirectPath + "#access_token=" + jwt;
|
||||
|
||||
String nonce = TauriSamlUtils.extractNonceFromRequest(request);
|
||||
if (nonce != null) {
|
||||
url +=
|
||||
"&nonce="
|
||||
+ java.net.URLEncoder.encode(
|
||||
nonce, java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the origin (frontend URL) for redirects. First checks system.frontendUrl from config,
|
||||
* then falls back to detecting from request headers.
|
||||
*/
|
||||
private String resolveOrigin(HttpServletRequest request) {
|
||||
// First check if frontendUrl is configured
|
||||
String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
||||
if (configuredFrontendUrl != null && !configuredFrontendUrl.trim().isEmpty()) {
|
||||
return configuredFrontendUrl.trim();
|
||||
}
|
||||
|
||||
// Fall back to auto-detection from request headers
|
||||
return resolveForwardedOrigin(request)
|
||||
.orElseGet(
|
||||
() ->
|
||||
resolveOriginFromReferer(request)
|
||||
.orElseGet(() -> buildOriginFromRequest(request)));
|
||||
}
|
||||
|
||||
private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
|
||||
if (TauriSamlUtils.isTauriRelayState(request)) {
|
||||
return TauriOAuthUtils.defaultTauriCallbackPath(contextPath);
|
||||
}
|
||||
return extractRedirectPathFromCookie(request)
|
||||
.filter(path -> path.startsWith("/"))
|
||||
.orElseGet(() -> defaultCallbackPath(contextPath));
|
||||
}
|
||||
|
||||
private Optional<String> extractRedirectPathFromCookie(HttpServletRequest request) {
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
for (Cookie cookie : cookies) {
|
||||
if (SPA_REDIRECT_COOKIE.equals(cookie.getName())) {
|
||||
String value = URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8).trim();
|
||||
if (!value.isEmpty()) {
|
||||
return Optional.of(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private String defaultCallbackPath(String contextPath) {
|
||||
if (contextPath == null
|
||||
|| contextPath.isBlank()
|
||||
|| "/".equals(contextPath)
|
||||
|| "\\".equals(contextPath)) {
|
||||
return DEFAULT_CALLBACK_PATH;
|
||||
}
|
||||
return contextPath + DEFAULT_CALLBACK_PATH;
|
||||
}
|
||||
|
||||
private Optional<String> resolveForwardedOrigin(HttpServletRequest request) {
|
||||
String forwardedHostHeader = request.getHeader("X-Forwarded-Host");
|
||||
if (forwardedHostHeader == null || forwardedHostHeader.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String host = forwardedHostHeader.split(",")[0].trim();
|
||||
if (host.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String forwardedProtoHeader = request.getHeader("X-Forwarded-Proto");
|
||||
String proto =
|
||||
(forwardedProtoHeader == null || forwardedProtoHeader.isBlank())
|
||||
? request.getScheme()
|
||||
: forwardedProtoHeader.split(",")[0].trim();
|
||||
|
||||
if (!host.contains(":")) {
|
||||
String forwardedPort = request.getHeader("X-Forwarded-Port");
|
||||
if (forwardedPort != null
|
||||
&& !forwardedPort.isBlank()
|
||||
&& !isDefaultPort(proto, forwardedPort.trim())) {
|
||||
host = host + ":" + forwardedPort.trim();
|
||||
}
|
||||
}
|
||||
return Optional.of(proto + "://" + host);
|
||||
}
|
||||
|
||||
private Optional<String> resolveOriginFromReferer(HttpServletRequest request) {
|
||||
String referer = request.getHeader("Referer");
|
||||
if (referer != null && !referer.isEmpty()) {
|
||||
try {
|
||||
URI refererUri = URI.create(referer);
|
||||
String host = refererUri.getHost();
|
||||
if (host == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String origin = refererUri.getScheme() + "://" + host;
|
||||
int port = refererUri.getPort();
|
||||
if (port != -1 && port != 80 && port != 443) {
|
||||
origin += ":" + port;
|
||||
}
|
||||
return Optional.of(origin);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.debug(
|
||||
"Malformed referer URL: {}, falling back to request-based origin", referer);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private String buildOriginFromRequest(HttpServletRequest request) {
|
||||
String scheme = request.getScheme();
|
||||
String serverName = request.getServerName();
|
||||
int serverPort = request.getServerPort();
|
||||
|
||||
StringBuilder origin = new StringBuilder();
|
||||
origin.append(scheme).append("://").append(serverName);
|
||||
|
||||
if ((!"http".equalsIgnoreCase(scheme) || serverPort != 80)
|
||||
&& (!"https".equalsIgnoreCase(scheme) || serverPort != 443)) {
|
||||
origin.append(":").append(serverPort);
|
||||
}
|
||||
|
||||
return origin.toString();
|
||||
}
|
||||
|
||||
private boolean isDefaultPort(String scheme, String port) {
|
||||
if (port == null) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
int parsedPort = Integer.parseInt(port);
|
||||
return ("http".equalsIgnoreCase(scheme) && parsedPort == 80)
|
||||
|| ("https".equalsIgnoreCase(scheme) && parsedPort == 443);
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void clearRedirectCookie(HttpServletResponse response) {
|
||||
// TODO: Migration required - was org.springframework.http.ResponseCookie with SameSite=Lax.
|
||||
// jakarta.servlet.Cookie has no SameSite setter on this servlet API level; SameSite=Lax is
|
||||
// dropped here. Set it via the new SAML SP servlet response or quarkus.http config if
|
||||
// needed.
|
||||
Cookie cookie = new Cookie(SPA_REDIRECT_COOKIE, "");
|
||||
cookie.setPath("/");
|
||||
cookie.setMaxAge(0);
|
||||
response.addCookie(cookie);
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -399,14 +399,18 @@ public class UserService implements UserServiceInterface {
|
||||
public void changePassword(User user, String newPassword)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
user.setPassword(passwordEncoder.encode(newPassword));
|
||||
userRepository.persist(user);
|
||||
// Spring Data save() upserted; Panache persist() rejects an already-persisted (detached)
|
||||
// entity ("Detached entity passed to persist"). The user was loaded in the request context,
|
||||
// so re-attach via merge to update it.
|
||||
userRepository.getEntityManager().merge(user);
|
||||
databaseService.exportDatabase();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void changeFirstUse(User user, boolean firstUse)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
user.setFirstLogin(firstUse);
|
||||
userRepository.persist(user);
|
||||
userRepository.getEntityManager().merge(user);
|
||||
databaseService.exportDatabase();
|
||||
}
|
||||
|
||||
|
||||
-289
@@ -1,289 +0,0 @@
|
||||
package stirling.software.proprietary.security;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
|
||||
@Disabled("TODO: Migration required - Spring Boot test framework not available in Quarkus")
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CustomLogoutSuccessHandlerTest {
|
||||
|
||||
@Mock private ApplicationProperties.Security securityProperties;
|
||||
|
||||
@Mock private JwtServiceInterface jwtService;
|
||||
|
||||
@InjectMocks private CustomLogoutSuccessHandler customLogoutSuccessHandler;
|
||||
|
||||
@Test
|
||||
void testSuccessfulLogout() throws IOException {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
String token = "token";
|
||||
String logoutPath = "/login?logout=true";
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(jwtService.extractToken(request)).thenReturn(token);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, null);
|
||||
|
||||
verify(response).sendRedirect(logoutPath);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccessfulLogoutViaJWT() throws IOException {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
String logoutPath = "/login?logout=true";
|
||||
String token = "token";
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(jwtService.extractToken(request)).thenReturn(token);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, null);
|
||||
|
||||
verify(response).sendRedirect(logoutPath);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccessfulLogoutViaOAuth2() throws IOException {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken oAuth2AuthenticationToken = mock(OAuth2AuthenticationToken.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oAuth2AuthenticationToken.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, oAuth2AuthenticationToken);
|
||||
|
||||
verify(response).sendRedirect("http://localhost:8080/login?logout=true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUserIsDisabledRedirect() throws IOException {
|
||||
String error = "userIsDisabled";
|
||||
String url = "http://localhost:8080";
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
|
||||
when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
|
||||
when(request.getParameter("maxUsersReached")).thenReturn(null);
|
||||
when(request.getParameter(error)).thenReturn("true");
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUserAlreadyExistsWebRedirect() throws IOException {
|
||||
String error = "oAuth2AuthenticationErrorWeb";
|
||||
String errorPath = "userAlreadyExistsWeb";
|
||||
String url = "http://localhost:8080";
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter(error)).thenReturn("true");
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
verify(response).sendRedirect(url + "/login?errorOAuth=" + errorPath);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testErrorOAuthRedirect() throws IOException {
|
||||
String error = "testError";
|
||||
String url = "http://localhost:8080";
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn("!!!" + error + "!!!");
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOAuth2AutoCreateDisabled() throws IOException {
|
||||
String error = "oAuth2AutoCreateDisabled";
|
||||
String url = "http://localhost:8080";
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter(error)).thenReturn("true");
|
||||
when(request.getContextPath()).thenReturn(url);
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOAuth2Error() throws IOException {
|
||||
String error = "test";
|
||||
String url = "http://localhost:8080";
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
|
||||
when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
|
||||
when(request.getParameter("maxUsersReached")).thenReturn(null);
|
||||
when(request.getParameter("userIsDisabled")).thenReturn(null);
|
||||
when(request.getParameter("error")).thenReturn("!@$!@£" + error + "£$%^*$");
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOAuth2BadCredentialsError() throws IOException {
|
||||
String error = "badCredentials";
|
||||
String url = "http://localhost:8080";
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
|
||||
when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
|
||||
when(request.getParameter("maxUsersReached")).thenReturn(null);
|
||||
when(request.getParameter("userIsDisabled")).thenReturn(null);
|
||||
when(request.getParameter("error")).thenReturn(null);
|
||||
when(request.getParameter(error)).thenReturn("true");
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOAuth2AdminBlockedUser() throws IOException {
|
||||
String error = "oAuth2AdminBlockedUser";
|
||||
String url = "http://localhost:8080";
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
|
||||
when(request.getParameter(error)).thenReturn("true");
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
|
||||
}
|
||||
}
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
|
||||
@Disabled("TODO: Migration required - Spring Boot test framework not available in Quarkus")
|
||||
class CustomOAuth2AuthenticationFailureHandlerTest {
|
||||
|
||||
@Test
|
||||
void redirectsToTauriCallbackWhenStateMarked() throws Exception {
|
||||
CustomOAuth2AuthenticationFailureHandler handler =
|
||||
new CustomOAuth2AuthenticationFailureHandler();
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setContextPath("");
|
||||
request.setParameter("state", "tauri:abc");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
handler.onAuthenticationFailure(
|
||||
request,
|
||||
response,
|
||||
new OAuth2AuthenticationException(new OAuth2Error("access_denied")));
|
||||
|
||||
assertEquals(
|
||||
"/auth/callback/tauri?state=tauri%3Aabc&errorOAuth=access_denied",
|
||||
response.getRedirectedUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void redirectsToDefaultCallbackWithoutTauriState() throws Exception {
|
||||
CustomOAuth2AuthenticationFailureHandler handler =
|
||||
new CustomOAuth2AuthenticationFailureHandler();
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setContextPath("");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
handler.onAuthenticationFailure(
|
||||
request,
|
||||
response,
|
||||
new OAuth2AuthenticationException(new OAuth2Error("access_denied")));
|
||||
|
||||
assertEquals("/auth/callback?errorOAuth=access_denied", response.getRedirectedUrl());
|
||||
}
|
||||
}
|
||||
@@ -50,25 +50,19 @@ RUN STIRLING_FLAVOR=${STIRLING_FLAVOR} \
|
||||
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
|
||||
--no-daemon
|
||||
|
||||
# Stage 2: Extract Spring Boot Layers
|
||||
FROM eclipse-temurin:25-jre-noble@sha256:b27ca47660a8fa837e47a8533b9b1a3a430295cf29ca28d91af4fd121572dc29 AS jar-extract
|
||||
WORKDIR /tmp
|
||||
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
|
||||
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
|
||||
|
||||
|
||||
# Stage 3: Final runtime image on top of pre-built base
|
||||
# Stage 2: Final runtime image on top of pre-built base
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
ARG VERSION_TAG
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Application layers
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/dependencies/ /app/
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/spring-boot-loader/ /app/
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/snapshot-dependencies/ /app/
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/application/ /app/
|
||||
# Quarkus uber runner-jar (self-contained, all dependencies inside). init-without-ocr.sh launches
|
||||
# it with `java -jar /app/app.jar`. Replaces the former Spring Boot layered-jar extraction
|
||||
# (java -Djarmode=tools extract --layers + spring-boot-loader layers), which the Quarkus uber-jar
|
||||
# does not support.
|
||||
COPY --link --from=app-build --chown=1000:1000 \
|
||||
/app/app/core/build/stirling-pdf-*-runner.jar /app/app.jar
|
||||
|
||||
COPY --link --from=app-build --chown=1000:1000 \
|
||||
/app/build/libs/restart-helper.jar /restart-helper.jar
|
||||
|
||||
@@ -46,25 +46,19 @@ RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
|
||||
--no-daemon
|
||||
|
||||
# Stage 2: Extract Spring Boot Layers
|
||||
FROM eclipse-temurin:25-jre-noble AS jar-extract
|
||||
WORKDIR /tmp
|
||||
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
|
||||
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
|
||||
|
||||
|
||||
# Stage 3: Final runtime image on top of pre-built base
|
||||
# Stage 2: Final runtime image on top of pre-built base
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
ARG VERSION_TAG
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Application layers
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/dependencies/ /app/
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/spring-boot-loader/ /app/
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/snapshot-dependencies/ /app/
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/application/ /app/
|
||||
# Quarkus uber runner-jar (self-contained, all dependencies inside). init-without-ocr.sh launches
|
||||
# it via `java -jar /app/app.jar`. Replaces the former Spring Boot layered-jar extraction
|
||||
# (java -Djarmode=tools extract --layers + spring-boot-loader layers), which the Quarkus uber-jar
|
||||
# does not support; build/libs/*.jar is only the thin (non-runnable) jar.
|
||||
COPY --link --from=app-build --chown=1000:1000 \
|
||||
/app/app/core/build/stirling-pdf-*-runner.jar /app/app.jar
|
||||
|
||||
COPY --link --from=app-build --chown=1000:1000 \
|
||||
/app/build/libs/restart-helper.jar /restart-helper.jar
|
||||
|
||||
@@ -110,9 +110,11 @@ COPY --chown=1000:1000 scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
|
||||
COPY --chown=1000:1000 scripts/installFonts.sh /scripts/installFonts.sh
|
||||
COPY --chown=1000:1000 scripts/stirling-diagnostics.sh /scripts/stirling-diagnostics.sh
|
||||
|
||||
# Copy built JARs from build stage
|
||||
# Copy the Quarkus uber runner-jar (self-contained). build/libs/*.jar is the thin jar (app classes
|
||||
# only, not runnable); the runnable artifact is build/<name>-runner.jar. init-without-ocr.sh runs
|
||||
# it via `java -jar /app.jar`.
|
||||
COPY --from=build --chown=1000:1000 \
|
||||
/app/app/core/build/libs/*.jar /app.jar
|
||||
/app/app/core/build/stirling-pdf-*-runner.jar /app.jar
|
||||
COPY --from=build --chown=1000:1000 \
|
||||
/app/build/libs/restart-helper.jar /restart-helper.jar
|
||||
|
||||
|
||||
@@ -32,9 +32,8 @@ find_jar() {
|
||||
[[ -f "$STIRLING_JAR" ]] || fail "STIRLING_JAR='$STIRLING_JAR' not found"
|
||||
candidate="$STIRLING_JAR"
|
||||
else
|
||||
candidate=$(find "$REPO_ROOT/app/core/build/libs" -maxdepth 1 -name 'Stirling-PDF*.jar' -o -name 'stirling-pdf*.jar' 2>/dev/null \
|
||||
| grep -vE '(-plain|-sources)\.jar$' | head -n 1 || true)
|
||||
[[ -n "$candidate" ]] || fail "No JAR under app/core/build/libs - run './gradlew :stirling-pdf:bootJar' first"
|
||||
candidate=$(find "$REPO_ROOT/app/core/build" -maxdepth 1 -name '*-runner.jar' 2>/dev/null | head -n 1 || true)
|
||||
[[ -n "$candidate" ]] || fail "No *-runner.jar under app/core/build - run './gradlew :stirling-pdf:quarkusBuild' first"
|
||||
fi
|
||||
# Resolve to an absolute path: test_fixture pushd's into a temp workdir
|
||||
# before launching java, so a relative path here would dangle.
|
||||
@@ -86,13 +85,17 @@ test_fixture() {
|
||||
# to cwd, and we want to make sure we hit the fixture's configs/ and not
|
||||
# whatever happens to live at the runner's working directory.
|
||||
pushd "$workdir" >/dev/null
|
||||
java -Xmx1g -jar "$jar" \
|
||||
"--server.port=$port" \
|
||||
"--spring.datasource.url=jdbc:h2:file:./configs/stirling-pdf-DB-2.3.232;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=TRUE;MODE=PostgreSQL" \
|
||||
"--spring.jpa.show-sql=false" \
|
||||
"--logging.level.root=WARN" \
|
||||
"--logging.level.stirling=INFO" \
|
||||
"--logging.level.org.hibernate.tool.schema=INFO" \
|
||||
# Quarkus reads config from -D system properties (which must precede -jar),
|
||||
# not Spring's post-jar --key=value args. Translated 1:1 from the former
|
||||
# Spring properties; system properties override application.properties.
|
||||
java -Xmx1g \
|
||||
"-Dquarkus.http.port=$port" \
|
||||
"-Dquarkus.datasource.jdbc.url=jdbc:h2:file:./configs/stirling-pdf-DB-2.3.232;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=TRUE;MODE=PostgreSQL" \
|
||||
"-Dquarkus.hibernate-orm.log.sql=false" \
|
||||
"-Dquarkus.log.level=WARN" \
|
||||
"-Dquarkus.log.category.stirling.level=INFO" \
|
||||
'-Dquarkus.log.category."org.hibernate.tool.schema".level=INFO' \
|
||||
-jar "$jar" \
|
||||
> "$log_file" 2>&1 &
|
||||
local pid=$!
|
||||
popd >/dev/null
|
||||
|
||||
@@ -953,13 +953,12 @@ JAVA_CMD=(
|
||||
if [ -f "/app.jar" ]; then
|
||||
JAVA_CMD+=("-jar" "/app.jar")
|
||||
elif [ -f "/app/app.jar" ]; then
|
||||
# Spring Boot 4 layered JAR structure (exploded via extract --layers).
|
||||
# Use -cp (not -jar) so the classpath matches the AOT cache exactly.
|
||||
JAVA_CMD+=("-cp" "/app/app.jar:/app/lib/*" "stirling.software.SPDF.SPDFApplication")
|
||||
# Quarkus uber runner-jar (self-contained, all dependencies inside). Launched via -jar;
|
||||
# the manifest Main-Class bootstraps the Quarkus application (@QuarkusMain SPDFApplication).
|
||||
JAVA_CMD+=("-jar" "/app/app.jar")
|
||||
else
|
||||
# Legacy fallback for Spring Boot 3 layered layout
|
||||
export JAVA_MAIN_CLASS=org.springframework.boot.loader.launch.JarLauncher
|
||||
JAVA_CMD+=("org.springframework.boot.loader.launch.JarLauncher")
|
||||
log "ERROR: no application jar found at /app.jar or /app/app.jar"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$CURRENT_USER" = "$RUNTIME_USER" ]; then
|
||||
@@ -1032,15 +1031,11 @@ if [ "$AOT_GENERATE_BACKGROUND" = true ]; then
|
||||
while [ "$_attempt" -le "$_max_attempts" ]; do
|
||||
log "AOT: Background cache generation attempt ${_attempt}/${_max_attempts}..."
|
||||
_gen_rc=0
|
||||
if [ -f /app/app.jar ] && [ -d /app/lib ]; then
|
||||
generate_aot_cache "$AOT_CACHE" \
|
||||
-cp "/app/app.jar:/app/lib/*" stirling.software.SPDF.SPDFApplication || _gen_rc=$?
|
||||
if [ -f /app/app.jar ]; then
|
||||
# Quarkus uber runner-jar (self-contained); mirror the runtime -jar launch.
|
||||
generate_aot_cache "$AOT_CACHE" -jar /app/app.jar || _gen_rc=$?
|
||||
elif [ -f /app.jar ]; then
|
||||
generate_aot_cache "$AOT_CACHE" -jar /app.jar || _gen_rc=$?
|
||||
elif [ -d /app/BOOT-INF ]; then
|
||||
# Spring Boot exploded layer layout, mirror the exact JAVA_CMD classpath
|
||||
generate_aot_cache "$AOT_CACHE" \
|
||||
-cp /app org.springframework.boot.loader.launch.JarLauncher || _gen_rc=$?
|
||||
else
|
||||
log "AOT: Cannot determine JAR layout; skipping cache generation."
|
||||
exit 0
|
||||
|
||||
Reference in New Issue
Block a user