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