Add Azure SSO bootstrap
Create a client registration from AZURE_* environment variables and disable SSO cleanly when no registration is available. Add a docker_push.sh helper for building and publishing production images.
This commit is contained in:
97
docker_push.sh
Executable file
97
docker_push.sh
Executable file
@@ -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}"
|
||||||
2
pom.xml
2
pom.xml
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<groupId>de.assecutor.emulatorstation</groupId>
|
<groupId>de.assecutor.emulatorstation</groupId>
|
||||||
<artifactId>emulatorstation</artifactId>
|
<artifactId>emulatorstation</artifactId>
|
||||||
<version>0.9.13</version>
|
<version>0.9.2</version>
|
||||||
|
|
||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -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<String> 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<String> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,9 +13,11 @@ import de.assecutor.emulatorstation.Application;
|
|||||||
public class SecurityConfig extends VaadinWebSecurity {
|
public class SecurityConfig extends VaadinWebSecurity {
|
||||||
|
|
||||||
private final LoginSessionService loginSessionService;
|
private final LoginSessionService loginSessionService;
|
||||||
|
private final SsoConfiguration ssoConfiguration;
|
||||||
|
|
||||||
public SecurityConfig(LoginSessionService loginSessionService) {
|
public SecurityConfig(LoginSessionService loginSessionService, SsoConfiguration ssoConfiguration) {
|
||||||
this.loginSessionService = loginSessionService;
|
this.loginSessionService = loginSessionService;
|
||||||
|
this.ssoConfiguration = ssoConfiguration;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -24,15 +26,18 @@ public class SecurityConfig extends VaadinWebSecurity {
|
|||||||
|
|
||||||
setLoginView(http, LoginView.class);
|
setLoginView(http, LoginView.class);
|
||||||
|
|
||||||
LogoutSuccessHandler logoutSuccessHandler = oidcLogoutSuccessHandler("{baseUrl}/login");
|
|
||||||
|
|
||||||
http.sessionManagement(session -> session
|
http.sessionManagement(session -> session
|
||||||
// Wichtig: Session-Fixation so konfigurieren, dass Session-Attribute (z.B. "user") erhalten bleiben
|
// Wichtig: Session-Fixation so konfigurieren, dass Session-Attribute (z.B. "user") erhalten bleiben
|
||||||
.sessionFixation(fixation -> fixation.migrateSession()).maximumSessions(Application.MAX_ACTIVE_SESSIONS)
|
.sessionFixation(fixation -> fixation.migrateSession()).maximumSessions(Application.MAX_ACTIVE_SESSIONS)
|
||||||
.maxSessionsPreventsLogin(false))
|
.maxSessionsPreventsLogin(false));
|
||||||
.oauth2Login(oauth2 -> oauth2.loginPage("/login").defaultSuccessUrl("/login", true)
|
|
||||||
.failureUrl("/login?error"))
|
if (ssoConfiguration.isEnabled()) {
|
||||||
.logout(logout -> {
|
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)
|
logout.logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET")).invalidateHttpSession(true)
|
||||||
.clearAuthentication(true).deleteCookies("JSESSIONID")
|
.clearAuthentication(true).deleteCookies("JSESSIONID")
|
||||||
.addLogoutHandler((request, response, authentication) -> {
|
.addLogoutHandler((request, response, authentication) -> {
|
||||||
@@ -48,6 +53,17 @@ public class SecurityConfig extends VaadinWebSecurity {
|
|||||||
logout.logoutSuccessUrl("/login");
|
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("/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
package de.assecutor.emulatorstation.base.ui.view.security;
|
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.annotation.Value;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class SsoConfiguration {
|
public class SsoConfiguration {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(SsoConfiguration.class);
|
||||||
|
|
||||||
private final boolean enabled;
|
private final boolean enabled;
|
||||||
|
|
||||||
public SsoConfiguration(@Value("${app.sso.enabled:true}") boolean enabled) {
|
public SsoConfiguration(@Value("${app.sso.enabled:true}") boolean enabled,
|
||||||
this.enabled = enabled;
|
ObjectProvider<ClientRegistrationRepository> 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() {
|
public boolean isEnabled() {
|
||||||
|
|||||||
Reference in New Issue
Block a user