diff --git a/docker_push.sh b/docker_push.sh
new file mode 100755
index 0000000..6aa5983
--- /dev/null
+++ b/docker_push.sh
@@ -0,0 +1,97 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+readonly SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+readonly PROJECT_DIR="${SCRIPT_DIR}"
+readonly DEFAULT_REGISTRY_IMAGE="registry.assecutor.org/emulatorstation"
+readonly REGISTRY_IMAGE="${REGISTRY_IMAGE:-${DEFAULT_REGISTRY_IMAGE}}"
+
+usage() {
+ cat <<'EOF'
+Verwendung:
+ ./docker_push.sh [x.y.z]
+
+Beispiele:
+ ./docker_push.sh 0.9.13
+ ./docker_push.sh
+
+Voraussetzungen:
+ - Docker Buildx ist installiert
+ - Login zur Registry wurde bereits ausgeführt:
+ docker login registry.assecutor.org
+
+Hinweise:
+ - Ohne Versionsargument wird die Version aus der pom.xml als Docker-Tag verwendet.
+ - Das JAR wird immer mit der aktuellen Projektversion aus der pom.xml gebaut.
+ - Das Ziel-Image kann optional per Umgebungsvariable überschrieben werden:
+ REGISTRY_IMAGE=registry.assecutor.org/mein-image ./docker_push.sh
+EOF
+}
+
+fail() {
+ echo "Fehler: $*" >&2
+ exit 1
+}
+
+require_command() {
+ command -v "$1" >/dev/null 2>&1 || fail "'$1' wurde nicht gefunden."
+}
+
+build_production_jar() {
+ echo "Baue Production-JAR ${JAR_FILE_REL} ..."
+ (
+ cd "${PROJECT_DIR}" && ./mvnw -Pproduction -DskipTests clean package
+ )
+
+ [[ -f "${JAR_FILE_ABS}" ]] || fail "Production-JAR wurde nicht gefunden: ${JAR_FILE_ABS}"
+}
+
+resolve_pom_value() {
+ local expression="$1"
+
+ [[ -x "${PROJECT_DIR}/mvnw" ]] || fail "'${PROJECT_DIR}/mvnw' wurde nicht gefunden oder ist nicht ausführbar."
+
+ local value
+ value="$(
+ cd "${PROJECT_DIR}" && ./mvnw -q -DforceStdout help:evaluate -Dexpression="${expression}" \
+ | awk 'NF { last = $0 } END { print last }'
+ )"
+
+ [[ -n "${value}" ]] || fail "Wert '${expression}' konnte nicht aus der pom.xml ermittelt werden."
+ echo "${value}"
+}
+
+if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
+ usage
+ exit 0
+fi
+
+readonly PROJECT_VERSION="$(resolve_pom_value project.version)"
+readonly ARTIFACT_ID="$(resolve_pom_value project.artifactId)"
+readonly IMAGE_TAG="${1:-${PROJECT_VERSION}}"
+
+if [[ ! "${IMAGE_TAG}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ fail "Versionsnummer muss das Format x.y.z haben."
+fi
+
+require_command docker
+docker buildx version >/dev/null 2>&1 || fail "Docker Buildx ist nicht verfügbar."
+
+readonly JAR_FILE_REL="target/${ARTIFACT_ID}-${PROJECT_VERSION}.jar"
+readonly JAR_FILE_ABS="${PROJECT_DIR}/${JAR_FILE_REL}"
+
+cd "${PROJECT_DIR}"
+
+echo "Verwende Build-Version ${PROJECT_VERSION} und Image-Tag ${IMAGE_TAG}."
+build_production_jar
+
+echo "Pushe Image ${REGISTRY_IMAGE}:${IMAGE_TAG} ..."
+docker buildx build \
+ --platform linux/amd64 \
+ -f "${PROJECT_DIR}/Dockerfile" \
+ -t "${REGISTRY_IMAGE}:${IMAGE_TAG}" \
+ --push \
+ "${PROJECT_DIR}"
+
+echo "Fertig: ${REGISTRY_IMAGE}:${IMAGE_TAG}"
diff --git a/pom.xml b/pom.xml
index 808e181..7f7e1ed 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
de.assecutor.emulatorstation
emulatorstation
- 0.9.13
+ 0.9.2
jar
diff --git a/src/main/bundles/prod.bundle b/src/main/bundles/prod.bundle
index ea49bcb..c3eb362 100644
Binary files a/src/main/bundles/prod.bundle and b/src/main/bundles/prod.bundle differ
diff --git a/src/main/java/de/assecutor/emulatorstation/base/ui/view/security/AzureClientRegistrationConfiguration.java b/src/main/java/de/assecutor/emulatorstation/base/ui/view/security/AzureClientRegistrationConfiguration.java
new file mode 100644
index 0000000..d67d852
--- /dev/null
+++ b/src/main/java/de/assecutor/emulatorstation/base/ui/view/security/AzureClientRegistrationConfiguration.java
@@ -0,0 +1,123 @@
+package de.assecutor.emulatorstation.base.ui.view.security;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Condition;
+import org.springframework.context.annotation.ConditionContext;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.type.AnnotatedTypeMetadata;
+import org.springframework.core.env.Environment;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
+import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
+import org.springframework.util.StringUtils;
+
+import java.util.Arrays;
+import java.util.List;
+
+@Configuration
+public class AzureClientRegistrationConfiguration {
+
+ private static final Logger logger = LoggerFactory.getLogger(AzureClientRegistrationConfiguration.class);
+
+ private static final String DEFAULT_REDIRECT_URI = "{baseUrl}/login/oauth2/code/{registrationId}";
+ private static final String DEFAULT_SCOPES = "openid,profile,email,offline_access";
+
+ @Bean
+ @ConditionalOnMissingBean(ClientRegistrationRepository.class)
+ @org.springframework.context.annotation.Conditional(AzureClientRegistrationAvailableCondition.class)
+ public ClientRegistrationRepository azureClientRegistrationRepository(Environment environment) {
+ String tenantId = resolveTenantId(environment);
+ String issuerUri = resolveIssuerUri(environment, tenantId);
+ String clientId = environment.getProperty("AZURE_CLIENT_ID");
+ String clientSecret = environment.getProperty("AZURE_CLIENT_SECRET");
+ String redirectUri = firstNonBlank(environment.getProperty("AZURE_REDIRECT_URI"), DEFAULT_REDIRECT_URI);
+ List scopes = parseScopes(firstNonBlank(environment.getProperty("AZURE_SCOPES"), DEFAULT_SCOPES));
+
+ ClientRegistration registration = ClientRegistration.withRegistrationId("azure").clientName("Azure")
+ .clientId(clientId).clientSecret(clientSecret)
+ .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+ .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE).redirectUri(redirectUri)
+ .scope(scopes).authorizationUri(buildAuthorizationUri(tenantId)).tokenUri(buildTokenUri(tenantId))
+ .jwkSetUri(buildJwkSetUri(tenantId)).issuerUri(issuerUri).userNameAttributeName(IdTokenClaimNames.SUB)
+ .build();
+
+ logger.info("Azure OAuth2 ClientRegistration wurde aus AZURE_* Umgebungsvariablen erstellt.");
+ return new InMemoryClientRegistrationRepository(registration);
+ }
+
+ static String resolveTenantId(Environment environment) {
+ String tenantId = environment.getProperty("AZURE_TENANT_ID");
+ if (StringUtils.hasText(tenantId)) {
+ return tenantId.trim();
+ }
+
+ String issuerUri = environment.getProperty("AZURE_ISSUER_URI");
+ if (!StringUtils.hasText(issuerUri)) {
+ return null;
+ }
+
+ String normalized = issuerUri.trim();
+ String marker = "login.microsoftonline.com/";
+ int markerIndex = normalized.indexOf(marker);
+ if (markerIndex < 0) {
+ return null;
+ }
+
+ String afterHost = normalized.substring(markerIndex + marker.length());
+ int slashIndex = afterHost.indexOf('/');
+ if (slashIndex < 0) {
+ return StringUtils.hasText(afterHost) ? afterHost : null;
+ }
+
+ String tenant = afterHost.substring(0, slashIndex);
+ return StringUtils.hasText(tenant) ? tenant : null;
+ }
+
+ static String resolveIssuerUri(Environment environment, String tenantId) {
+ String issuerUri = environment.getProperty("AZURE_ISSUER_URI");
+ if (StringUtils.hasText(issuerUri)) {
+ return issuerUri.trim();
+ }
+ if (!StringUtils.hasText(tenantId)) {
+ return null;
+ }
+ return "https://login.microsoftonline.com/" + tenantId + "/v2.0";
+ }
+
+ private static String buildAuthorizationUri(String tenantId) {
+ return "https://login.microsoftonline.com/" + tenantId + "/oauth2/v2.0/authorize";
+ }
+
+ private static String buildTokenUri(String tenantId) {
+ return "https://login.microsoftonline.com/" + tenantId + "/oauth2/v2.0/token";
+ }
+
+ private static String buildJwkSetUri(String tenantId) {
+ return "https://login.microsoftonline.com/" + tenantId + "/discovery/v2.0/keys";
+ }
+
+ private static List parseScopes(String scopesValue) {
+ return Arrays.stream(scopesValue.split(",")).map(String::trim).filter(StringUtils::hasText).toList();
+ }
+
+ private static String firstNonBlank(String value, String fallback) {
+ return StringUtils.hasText(value) ? value.trim() : fallback;
+ }
+
+ static final class AzureClientRegistrationAvailableCondition implements Condition {
+
+ @Override
+ public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
+ Environment environment = context.getEnvironment();
+ return StringUtils.hasText(environment.getProperty("AZURE_CLIENT_ID"))
+ && StringUtils.hasText(environment.getProperty("AZURE_CLIENT_SECRET"))
+ && StringUtils.hasText(resolveTenantId(environment));
+ }
+ }
+}
diff --git a/src/main/java/de/assecutor/emulatorstation/base/ui/view/security/SecurityConfig.java b/src/main/java/de/assecutor/emulatorstation/base/ui/view/security/SecurityConfig.java
index db7efc5..0b49e72 100644
--- a/src/main/java/de/assecutor/emulatorstation/base/ui/view/security/SecurityConfig.java
+++ b/src/main/java/de/assecutor/emulatorstation/base/ui/view/security/SecurityConfig.java
@@ -13,9 +13,11 @@ import de.assecutor.emulatorstation.Application;
public class SecurityConfig extends VaadinWebSecurity {
private final LoginSessionService loginSessionService;
+ private final SsoConfiguration ssoConfiguration;
- public SecurityConfig(LoginSessionService loginSessionService) {
+ public SecurityConfig(LoginSessionService loginSessionService, SsoConfiguration ssoConfiguration) {
this.loginSessionService = loginSessionService;
+ this.ssoConfiguration = ssoConfiguration;
}
@Override
@@ -24,30 +26,44 @@ public class SecurityConfig extends VaadinWebSecurity {
setLoginView(http, LoginView.class);
- LogoutSuccessHandler logoutSuccessHandler = oidcLogoutSuccessHandler("{baseUrl}/login");
-
http.sessionManagement(session -> session
// Wichtig: Session-Fixation so konfigurieren, dass Session-Attribute (z.B. "user") erhalten bleiben
.sessionFixation(fixation -> fixation.migrateSession()).maximumSessions(Application.MAX_ACTIVE_SESSIONS)
- .maxSessionsPreventsLogin(false))
- .oauth2Login(oauth2 -> oauth2.loginPage("/login").defaultSuccessUrl("/login", true)
- .failureUrl("/login?error"))
- .logout(logout -> {
- logout.logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET")).invalidateHttpSession(true)
- .clearAuthentication(true).deleteCookies("JSESSIONID")
- .addLogoutHandler((request, response, authentication) -> {
- var session = request.getSession(false);
- if (session != null) {
- loginSessionService.cleanupApplicationSession(session.getId());
- }
- });
+ .maxSessionsPreventsLogin(false));
- if (logoutSuccessHandler != null) {
- logout.logoutSuccessHandler(logoutSuccessHandler);
- } else {
- logout.logoutSuccessUrl("/login");
+ if (ssoConfiguration.isEnabled()) {
+ LogoutSuccessHandler logoutSuccessHandler = oidcLogoutSuccessHandler("{baseUrl}/login");
+
+ http.oauth2Login(
+ oauth2 -> oauth2.loginPage("/login").defaultSuccessUrl("/login", true).failureUrl("/login?error"));
+
+ http.logout(logout -> {
+ logout.logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET")).invalidateHttpSession(true)
+ .clearAuthentication(true).deleteCookies("JSESSIONID")
+ .addLogoutHandler((request, response, authentication) -> {
+ var session = request.getSession(false);
+ if (session != null) {
+ loginSessionService.cleanupApplicationSession(session.getId());
+ }
+ });
+
+ if (logoutSuccessHandler != null) {
+ logout.logoutSuccessHandler(logoutSuccessHandler);
+ } else {
+ logout.logoutSuccessUrl("/login");
+ }
+ });
+ return;
+ }
+
+ http.logout(logout -> logout.logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET"))
+ .invalidateHttpSession(true).clearAuthentication(true).deleteCookies("JSESSIONID")
+ .addLogoutHandler((request, response, authentication) -> {
+ var session = request.getSession(false);
+ if (session != null) {
+ loginSessionService.cleanupApplicationSession(session.getId());
}
- });
+ }).logoutSuccessUrl("/"));
}
}
diff --git a/src/main/java/de/assecutor/emulatorstation/base/ui/view/security/SsoConfiguration.java b/src/main/java/de/assecutor/emulatorstation/base/ui/view/security/SsoConfiguration.java
index b7705f4..4e8a442 100644
--- a/src/main/java/de/assecutor/emulatorstation/base/ui/view/security/SsoConfiguration.java
+++ b/src/main/java/de/assecutor/emulatorstation/base/ui/view/security/SsoConfiguration.java
@@ -1,15 +1,28 @@
package de.assecutor.emulatorstation.base.ui.view.security;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.stereotype.Service;
@Service
public class SsoConfiguration {
+ private static final Logger logger = LoggerFactory.getLogger(SsoConfiguration.class);
+
private final boolean enabled;
- public SsoConfiguration(@Value("${app.sso.enabled:true}") boolean enabled) {
- this.enabled = enabled;
+ public SsoConfiguration(@Value("${app.sso.enabled:true}") boolean enabled,
+ ObjectProvider clientRegistrationRepositoryProvider) {
+ boolean clientRegistrationAvailable = clientRegistrationRepositoryProvider.getIfAvailable() != null;
+ this.enabled = enabled && clientRegistrationAvailable;
+
+ if (enabled && !clientRegistrationAvailable) {
+ logger.warn("SSO ist aktiviert, aber keine OAuth2-Client-Registrierung verfuegbar. "
+ + "Die Anwendung startet daher mit deaktiviertem SSO.");
+ }
}
public boolean isEnabled() {