Compare commits

...

3 Commits

Author SHA1 Message Date
8b313cd588 Show SIM occupancy badge and add release action
- SimCardConfigurationView: render dropdown rows with a "Belegt durch <SID>"
  badge for SIM cards that have a usr_id, plus an inline unlink button that
  opens a confirmation dialog and releases the assignment.
- SimCardAssignmentService: add releaseSimCard via DELETE
  /api/simcards/{id}/assign.
- MainView: after install, auto-start the installed app by resolving the
  third-party package via "pm list packages -3" and launching it through
  monkey.
- SecurityConfig: route unauthenticated users to SimCardConfigurationView
  when SSO is disabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:03:25 +02:00
9a67832faa 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.
2026-04-02 12:48:51 +02:00
4fc5b04a68 Add SIM card management and emulator container service
- Add SimCardAssignmentService for managing SIM card assignments
- Add SimCardConfigurationView for UI-based SIM card configuration
- Add EmulatorContainerService for container lifecycle management
- Add NiederlassungResolver for branch/location resolution
- Add new POJOs: CourierInfo and SimCardInfo
- Refactor security: introduce SsoConfiguration and LoginSessionService
- Remove deprecated AdminView and VaadinAccessHandler
- Add unit tests for core services
2026-03-31 11:07:39 +02:00
27 changed files with 1347 additions and 418 deletions

2
.gitignore vendored
View File

@@ -4,6 +4,8 @@
.settings .settings
.project .project
.classpath .classpath
application-secrets.properties
.env
*.iml *.iml
.DS_Store .DS_Store

97
docker_push.sh Executable file
View 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}"

View File

@@ -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>
@@ -75,6 +75,10 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId> <artifactId>spring-boot-starter-security</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId> <artifactId>spring-boot-starter-validation</artifactId>

Binary file not shown.

View File

@@ -3,14 +3,13 @@ package de.assecutor.emulatorstation;
import com.vaadin.flow.component.page.AppShellConfigurator; import com.vaadin.flow.component.page.AppShellConfigurator;
import com.vaadin.flow.component.page.Push; import com.vaadin.flow.component.page.Push;
import com.vaadin.flow.theme.Theme; import com.vaadin.flow.theme.Theme;
import de.assecutor.emulatorstation.pojo.NiederlassungInfo;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import de.assecutor.emulatorstation.pojo.UserInfo;
import de.assecutor.emulatorstation.pojo.NiederlassungInfo;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@Push @Push
@@ -18,9 +17,6 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@Theme("default") @Theme("default")
public class Application implements AppShellConfigurator { public class Application implements AppShellConfigurator {
// Single user configuration
public static final String SINGLE_USERNAME = "user";
public static final String SINGLE_PASSWORD = "user123";
public static final int MAX_ACTIVE_SESSIONS = 5; public static final int MAX_ACTIVE_SESSIONS = 5;
public static final Map<String, NiederlassungInfo> niederlassungen = Map.ofEntries( public static final Map<String, NiederlassungInfo> niederlassungen = Map.ofEntries(
@@ -32,7 +28,8 @@ public class Application implements AppShellConfigurator {
Map.entry("Dresden", new NiederlassungInfo("Dresden", "172.18.0.106", "6086", "/dresden")), Map.entry("Dresden", new NiederlassungInfo("Dresden", "172.18.0.106", "6086", "/dresden")),
Map.entry("Hannover", new NiederlassungInfo("Hannover", "172.18.0.104", "6084", "/hannover")), Map.entry("Hannover", new NiederlassungInfo("Hannover", "172.18.0.104", "6084", "/hannover")),
Map.entry("Stuttgart", new NiederlassungInfo("Stuttgart", "172.18.0.111", "6091", "/stuttgart")), Map.entry("Stuttgart", new NiederlassungInfo("Stuttgart", "172.18.0.111", "6091", "/stuttgart")),
Map.entry("Frankfurt am Main", new NiederlassungInfo("Frankfurt am Main", "172.18.0.105", "6085", "/frankfurt")), Map.entry("Frankfurt am Main",
new NiederlassungInfo("Frankfurt am Main", "172.18.0.105", "6085", "/frankfurt")),
Map.entry("Geschäftführung", new NiederlassungInfo("Geschäftführung", "172.18.0.112", "6092", "/gfl"))); Map.entry("Geschäftführung", new NiederlassungInfo("Geschäftführung", "172.18.0.112", "6092", "/gfl")));
public static final java.util.Map<String, com.vaadin.flow.server.WrappedSession> sessionsById = new ConcurrentHashMap<>(); public static final java.util.Map<String, com.vaadin.flow.server.WrappedSession> sessionsById = new ConcurrentHashMap<>();

View File

@@ -1,6 +1,7 @@
package de.assecutor.emulatorstation.base.domain; package de.assecutor.emulatorstation.base.domain;
import de.assecutor.emulatorstation.Application; import de.assecutor.emulatorstation.Application;
import de.assecutor.emulatorstation.base.ui.view.security.EmulatorServerConfiguration;
import de.assecutor.emulatorstation.pojo.NiederlassungInfo; import de.assecutor.emulatorstation.pojo.NiederlassungInfo;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -17,9 +18,12 @@ public class ContainerShutdownScheduler {
private static final Logger logger = LoggerFactory.getLogger(ContainerShutdownScheduler.class); private static final Logger logger = LoggerFactory.getLogger(ContainerShutdownScheduler.class);
private static final String DOCKER_HOST = "172.16.0.158"; // gleiches Fallback wie in MainView/LoginView
private final HttpClient http = HttpClient.newHttpClient(); private final HttpClient http = HttpClient.newHttpClient();
private final EmulatorServerConfiguration emulatorServerConfiguration;
public ContainerShutdownScheduler(EmulatorServerConfiguration emulatorServerConfiguration) {
this.emulatorServerConfiguration = emulatorServerConfiguration;
}
// Täglich um 22:00 Uhr Serverzeit // Täglich um 22:00 Uhr Serverzeit
@Scheduled(cron = "0 0 22 * * *") @Scheduled(cron = "0 0 22 * * *")
@@ -40,8 +44,10 @@ public class ContainerShutdownScheduler {
logger.info("[Scheduler] Nächtliches Herunterfahren abgeschlossen"); logger.info("[Scheduler] Nächtliches Herunterfahren abgeschlossen");
// Alle Sessions beenden (Logout erzwingen) // Alle Sessions beenden (Logout erzwingen)
logger.info("[Scheduler] Beginne Session-Logout für alle aktiven Sessions: {}", Application.sessionsById.size()); logger.info("[Scheduler] Beginne Session-Logout für alle aktiven Sessions: {}",
for (com.vaadin.flow.server.WrappedSession session : new java.util.ArrayList<>(Application.sessionsById.values())) { Application.sessionsById.size());
for (com.vaadin.flow.server.WrappedSession session : new java.util.ArrayList<>(
Application.sessionsById.values())) {
try { try {
session.invalidate(); session.invalidate();
} catch (Exception e) { } catch (Exception e) {
@@ -52,7 +58,8 @@ public class ContainerShutdownScheduler {
Application.activeSessions.clear(); Application.activeSessions.clear();
Application.activeNiederlassungen.clear(); Application.activeNiederlassungen.clear();
Application.sessionsById.clear(); Application.sessionsById.clear();
logger.info("[Scheduler] Session-Logout abgeschlossen. Aktive Sessions: {} / Maps geleert.", Application.activeSessions.size()); logger.info("[Scheduler] Session-Logout abgeschlossen. Aktive Sessions: {} / Maps geleert.",
Application.activeSessions.size());
} }
@@ -61,10 +68,9 @@ public class ContainerShutdownScheduler {
} }
private boolean isRunning(String containerName) throws Exception { private boolean isRunning(String containerName) throws Exception {
String dockerHost = emulatorServerConfiguration.getServerIp();
HttpRequest req = HttpRequest.newBuilder() HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("http://" + DOCKER_HOST + ":2375/containers/" + containerName + "/json")) .uri(URI.create("http://" + dockerHost + ":2375/containers/" + containerName + "/json")).GET().build();
.GET()
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) { if (resp.statusCode() == 200) {
String body = resp.body(); String body = resp.body();
@@ -81,10 +87,10 @@ public class ContainerShutdownScheduler {
} }
private void stopContainer(String containerName) throws Exception { private void stopContainer(String containerName) throws Exception {
String dockerHost = emulatorServerConfiguration.getServerIp();
HttpRequest req = HttpRequest.newBuilder() HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("http://" + DOCKER_HOST + ":2375/containers/" + containerName + "/stop")) .uri(URI.create("http://" + dockerHost + ":2375/containers/" + containerName + "/stop"))
.POST(HttpRequest.BodyPublishers.noBody()) .POST(HttpRequest.BodyPublishers.noBody()).build();
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
int status = resp.statusCode(); int status = resp.statusCode();
if (status == 204 || status == 304) { if (status == 204 || status == 304) {
@@ -92,8 +98,8 @@ public class ContainerShutdownScheduler {
} else if (status == 404) { } else if (status == 404) {
logger.info("[Scheduler] Container '{}' nicht gefunden (404)", containerName); logger.info("[Scheduler] Container '{}' nicht gefunden (404)", containerName);
} else { } else {
logger.warn("[Scheduler] Unerwarteter Status beim Stoppen von '{}' -> {} / {}", containerName, status, resp.body()); logger.warn("[Scheduler] Unerwarteter Status beim Stoppen von '{}' -> {} / {}", containerName, status,
resp.body());
} }
} }
} }

View File

@@ -0,0 +1,44 @@
package de.assecutor.emulatorstation.base.domain;
import de.assecutor.emulatorstation.base.ui.view.security.EmulatorServerConfiguration;
import de.assecutor.emulatorstation.pojo.NiederlassungInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@Service
public class EmulatorContainerService {
private static final Logger logger = LoggerFactory.getLogger(EmulatorContainerService.class);
private final EmulatorServerConfiguration emulatorServerConfiguration;
public EmulatorContainerService(EmulatorServerConfiguration emulatorServerConfiguration) {
this.emulatorServerConfiguration = emulatorServerConfiguration;
}
public boolean isContainerRunning(NiederlassungInfo info) {
if (info == null) {
return false;
}
String server = emulatorServerConfiguration.getServerIp();
String containerName = "android-container-" + info.name();
try {
var client = java.net.http.HttpClient.newHttpClient();
var request = java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create("http://" + server + ":2375/containers/" + containerName + "/json")).GET()
.build();
var response = client.send(request, java.net.http.HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
String body = response.body();
return body != null && body.contains("\"Running\":true");
}
} catch (Exception e) {
logger.warn("Fehler beim Pruefen des Container-Status fuer '{}': {}", containerName, e.toString());
}
return false;
}
}

View File

@@ -0,0 +1,46 @@
package de.assecutor.emulatorstation.base.domain;
import de.assecutor.emulatorstation.Application;
import de.assecutor.emulatorstation.pojo.NiederlassungInfo;
import de.assecutor.emulatorstation.pojo.SimCardInfo;
import org.springframework.stereotype.Service;
import java.util.Locale;
import java.util.Map;
@Service
public class NiederlassungResolver {
private static final String SIM_PREFIX = "EMU_";
private static final Map<String, String> NIEDERLASSUNG_BY_SIM_CODE = Map.ofEntries(Map.entry("B", "Berlin"),
Map.entry("HB", "Bremen"), Map.entry("HH", "Hamburg"), Map.entry("E", "Essen"), Map.entry("L", "Leipzig"),
Map.entry("DD", "Dresden"), Map.entry("H", "Hannover"), Map.entry("S", "Stuttgart"),
Map.entry("F", "Frankfurt am Main"), Map.entry("GFL", "Geschäftführung"));
public NiederlassungInfo resolveFromSimCard(SimCardInfo simCard) {
return simCard == null ? null : resolveFromSecretValue(simCard.secretValue());
}
public NiederlassungInfo resolveFromSecretValue(String secretValue) {
String code = extractCode(secretValue);
if (code == null) {
return null;
}
String niederlassungName = NIEDERLASSUNG_BY_SIM_CODE.get(code);
return niederlassungName == null ? null : Application.niederlassungen.get(niederlassungName);
}
String extractCode(String secretValue) {
if (secretValue == null) {
return null;
}
String normalized = secretValue.trim().toUpperCase(Locale.ROOT);
if (!normalized.startsWith(SIM_PREFIX) || normalized.length() <= SIM_PREFIX.length()) {
return null;
}
return normalized.substring(SIM_PREFIX.length());
}
}

View File

@@ -0,0 +1,133 @@
package de.assecutor.emulatorstation.base.domain;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.emulatorstation.base.ui.view.security.EmulatorServerConfiguration;
import de.assecutor.emulatorstation.pojo.CourierInfo;
import de.assecutor.emulatorstation.pojo.SimCardInfo;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
@Service
public class SimCardAssignmentService {
private final HttpClient httpClient = HttpClient.newHttpClient();
private final ObjectMapper objectMapper = new ObjectMapper();
private final EmulatorServerConfiguration emulatorServerConfiguration;
public SimCardAssignmentService(EmulatorServerConfiguration emulatorServerConfiguration) {
this.emulatorServerConfiguration = emulatorServerConfiguration;
}
public List<CourierInfo> fetchCouriers() {
try {
HttpResponse<String> response = sendGet("/api/couriers");
JsonNode root = objectMapper.readTree(response.body());
List<CourierInfo> couriers = new ArrayList<>();
addCouriers(couriers, root.path("test"), "test");
addCouriers(couriers, root.path("live"), "live");
couriers.sort(Comparator.comparing(CourierInfo::environment).thenComparing(CourierInfo::courierSid));
return couriers;
} catch (IOException e) {
throw new IllegalStateException("Kuriere konnten nicht geladen werden.", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Laden der Kuriere wurde unterbrochen.", e);
}
}
public List<SimCardInfo> fetchSimCards() {
try {
HttpResponse<String> response = sendGet("/api/simcards");
JsonNode root = objectMapper.readTree(response.body());
List<SimCardInfo> simCards = new ArrayList<>();
if (root.isArray()) {
for (JsonNode item : root) {
simCards.add(new SimCardInfo(item.path("cp_id").asLong(), item.path("cust_id").asLong(),
item.path("usr_id").isNull() ? null : item.path("usr_id").asLong(),
item.path("cp_secval").asText("")));
}
}
simCards.sort(Comparator.comparing(SimCardInfo::secretValue));
return simCards;
} catch (IOException e) {
throw new IllegalStateException("SIM-Karten konnten nicht geladen werden.", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Laden der SIM-Karten wurde unterbrochen.", e);
}
}
public void releaseSimCard(SimCardInfo simCard) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(buildUri("/api/simcards/" + simCard.simCardId() + "/assign")).DELETE().build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
ensureSuccessful(response, "Zuordnung der SIM-Karte konnte nicht aufgehoben werden.");
} catch (IOException e) {
throw new IllegalStateException("Zuordnung der SIM-Karte konnte nicht aufgehoben werden.", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Aufheben der Zuordnung wurde unterbrochen.", e);
}
}
public void assignSimCard(CourierInfo courier, SimCardInfo simCard) {
try {
String payload = objectMapper.writeValueAsString(
Map.of("userId", courier.userId(), "simCardId", simCard.simCardId(), "env", courier.environment()));
HttpRequest request = HttpRequest.newBuilder().uri(buildUri("/api/simcards/assign"))
.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
ensureSuccessful(response, "SIM-Karte konnte nicht zugeordnet werden.");
} catch (IOException e) {
throw new IllegalStateException("SIM-Karte konnte nicht zugeordnet werden.", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Zuordnung der SIM-Karte wurde unterbrochen.", e);
}
}
private void addCouriers(List<CourierInfo> couriers, JsonNode nodes, String fallbackEnvironment) {
if (!nodes.isArray()) {
return;
}
for (JsonNode item : nodes) {
couriers.add(new CourierInfo(item.path("cr_id").asLong(), item.path("cr_sid").asText(""),
item.path("usr_id").asLong(), item.path("usr_account").asText(""),
item.path("env").asText(fallbackEnvironment)));
}
}
private HttpResponse<String> sendGet(String path) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder().uri(buildUri(path)).GET().build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
ensureSuccessful(response, "API-Daten konnten nicht geladen werden.");
return response;
}
private void ensureSuccessful(HttpResponse<String> response, String message) {
int status = response.statusCode();
if (status < 200 || status >= 300) {
throw new IllegalStateException(message + " Status=" + status + " Body=" + response.body());
}
}
private URI buildUri(String path) {
return URI.create("http://" + emulatorServerConfiguration.getServerIp() + ":8086" + path);
}
}

View File

@@ -1,74 +0,0 @@
package de.assecutor.emulatorstation.base.ui.view;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.html.Main;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.Menu;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.VaadinService;
import com.vaadin.flow.theme.lumo.LumoUtility;
import jakarta.annotation.security.PermitAll;
import util.PreferencesKeyValueStore;
@Route("admin")
@PageTitle("Admin")
@Menu(order = 0, icon = "vaadin:clipboard-check", title = "Admin")
@PermitAll // When security is enabled, allow all authenticated users
public class AdminView extends Main {
final TextField serverPortTextView;
final Button saveBtn;
final Button logoutBtn;
final PreferencesKeyValueStore preferences = new PreferencesKeyValueStore();
public AdminView() {
addClassName(LumoUtility.Padding.MEDIUM);
var verticalLayout = new VerticalLayout();
add(verticalLayout);
serverPortTextView = new TextField();
serverPortTextView.setPlaceholder("Server");
serverPortTextView.setValue("172.16.0.158");
serverPortTextView.setMinWidth("20em");
verticalLayout.add(serverPortTextView);
var serverPort = preferences.get("server");
serverPort.ifPresent(serverPortTextView::setValue);
var horizontalLayout = new HorizontalLayout();
add(horizontalLayout);
saveBtn = new Button("Save", event -> savePreferences());
saveBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
horizontalLayout.add(saveBtn);
logoutBtn = new Button("Logout", event -> logout());
logoutBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
horizontalLayout.add(logoutBtn);
setSizeFull();
}
private void logout() {
VaadinService.getCurrentRequest().getWrappedSession().invalidate();
// Navigiere den Benutzer zur Main-Seite
UI.getCurrent().navigate("main");
}
private void savePreferences() {
var preferences = new PreferencesKeyValueStore();
preferences.put("server", serverPortTextView.getValue());
Notification.show("Daten gespeichert!", 3000, Notification.Position.MIDDLE);
}
}

View File

@@ -1,147 +1,68 @@
package de.assecutor.emulatorstation.base.ui.view; package de.assecutor.emulatorstation.base.ui.view;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.select.Select;
import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant; import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.textfield.PasswordField; import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.icon.Icon; import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon; import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.AfterNavigationEvent;
import com.vaadin.flow.router.AfterNavigationObserver;
import com.vaadin.flow.router.Route; import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.auth.AnonymousAllowed; import com.vaadin.flow.server.auth.AnonymousAllowed;
import de.assecutor.emulatorstation.Application; import com.vaadin.flow.spring.security.AuthenticationContext;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import de.assecutor.emulatorstation.base.ui.view.security.LoginSessionService;
import org.springframework.security.core.authority.SimpleGrantedAuthority; import de.assecutor.emulatorstation.base.ui.view.security.SsoConfiguration;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
@Route("login") @Route("login")
@AnonymousAllowed @AnonymousAllowed
public class LoginView extends VerticalLayout { public class LoginView extends VerticalLayout implements AfterNavigationObserver {
private static final Logger logger = LoggerFactory.getLogger(LoginView.class); private final AuthenticationContext authenticationContext;
private final LoginSessionService loginSessionService;
private final SsoConfiguration ssoConfiguration;
public LoginView(AuthenticationContext authenticationContext, LoginSessionService loginSessionService,
SsoConfiguration ssoConfiguration) {
this.authenticationContext = authenticationContext;
this.loginSessionService = loginSessionService;
this.ssoConfiguration = ssoConfiguration;
public LoginView() {
setSizeFull(); setSizeFull();
setAlignItems(Alignment.CENTER); setAlignItems(Alignment.CENTER);
setJustifyContentMode(JustifyContentMode.CENTER); setJustifyContentMode(JustifyContentMode.CENTER);
getStyle().set("background", "linear-gradient(135deg, #667eea 0%, #764ba2 100%)").set("min-height", "100vh") getStyle().set("background", "linear-gradient(135deg, #667eea 0%, #764ba2 100%)").set("min-height", "100vh")
.set("padding", "20px"); .set("padding", "20px");
// Haupt-Container für das Login-Formular
Div loginContainer = new Div(); Div loginContainer = new Div();
loginContainer.getStyle().set("background-color", "#ffffff").set("border-radius", "12px") loginContainer.getStyle().set("background-color", "#ffffff").set("border-radius", "12px")
.set("box-shadow", "0 10px 30px rgba(0,0,0,0.2)").set("padding", "40px").set("max-width", "400px") .set("box-shadow", "0 10px 30px rgba(0,0,0,0.2)").set("padding", "40px").set("max-width", "440px")
.set("width", "100%").set("text-align", "center"); .set("width", "100%").set("text-align", "center");
// Logo/Icon
Icon icon = new Icon(VaadinIcon.DESKTOP); Icon icon = new Icon(VaadinIcon.DESKTOP);
icon.setSize("64px"); icon.setSize("64px");
icon.getStyle().set("color", "#667eea").set("margin-bottom", "20px"); icon.getStyle().set("color", "#667eea").set("margin-bottom", "20px");
// Titel
H1 title = new H1("Emulator Station"); H1 title = new H1("Emulator Station");
title.getStyle().set("margin", "0 0 8px 0").set("color", "#333333").set("font-size", "2rem").set("font-weight", title.getStyle().set("margin", "0 0 8px 0").set("color", "#333333").set("font-size", "2rem").set("font-weight",
"600"); "600");
// Untertitel
Paragraph subtitle = new Paragraph("Melden Sie sich an, um fortzufahren");
subtitle.getStyle().set("margin", "0 0 30px 0").set("color", "#666666").set("font-size", "0.95rem");
// Form-Container
VerticalLayout formLayout = new VerticalLayout(); VerticalLayout formLayout = new VerticalLayout();
formLayout.setSpacing(true); formLayout.setSpacing(true);
formLayout.setPadding(false); formLayout.setPadding(false);
formLayout.setWidthFull(); formLayout.setWidthFull();
// Eingabefelder - nur noch Passwort Button loginButton = new Button("Mit Microsoft anmelden", new Icon(VaadinIcon.SIGN_IN), event -> startLogin());
PasswordField passwordField = new PasswordField("Passwort");
passwordField.setWidthFull();
passwordField.getStyle().set("margin-bottom", "16px");
Select<String> niederlassungSelect = new Select<>();
niederlassungSelect.setLabel("Niederlassung");
niederlassungSelect.setItems(Application.niederlassungen.keySet().stream().sorted().toList());
niederlassungSelect.setPlaceholder("Wählen Sie eine Niederlassung");
niederlassungSelect.setWidthFull();
niederlassungSelect.getStyle().set("margin-bottom", "24px");
Button loginButton = new Button("Anmelden", new Icon(VaadinIcon.SIGN_IN), event -> {
String password = passwordField.getValue();
String niederlassung = niederlassungSelect.getValue();
// Validierung der Eingabefelder
if (password.isEmpty() || niederlassung == null) {
Notification.show("Bitte alle Felder ausfüllen", 3000, Notification.Position.MIDDLE);
return;
}
// Passwort prüfen
if (!Application.SINGLE_PASSWORD.equals(password)) {
Notification.show("Ungültiges Passwort", 3000, Notification.Position.MIDDLE);
return;
}
// Maximale Session-Anzahl prüfen
if (Application.activeSessions.size() >= Application.MAX_ACTIVE_SESSIONS) {
Notification.show(
"Maximale Anzahl von " + Application.MAX_ACTIVE_SESSIONS
+ " gleichzeitigen Anmeldungen erreicht. Bitte versuchen Sie es später erneut.",
5000, Notification.Position.MIDDLE);
return;
}
var niederlassungInfo = Application.niederlassungen.get(niederlassung);
if (niederlassungInfo == null) {
Notification.show("Ausgewählte Niederlassung ist ungültig", 3000, Notification.Position.MIDDLE);
return;
}
getUI().ifPresent(ui -> {
if (isContainerRunning(niederlassungInfo)) {
var dialog = new com.vaadin.flow.component.dialog.Dialog();
dialog.setHeaderTitle("Container läuft bereits");
var text = new Paragraph("Für '" + niederlassungInfo.name() + "' läuft bereits ein Emulator-Container. Möchten Sie ihn für diese Session übernehmen?");
var adopt = new Button("Übernehmen", e2 -> {
dialog.close();
completeLogin(ui, password, niederlassungInfo, true);
});
adopt.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
var cancel = new Button("Abbrechen", e2 -> dialog.close());
var buttons = new com.vaadin.flow.component.orderedlayout.HorizontalLayout(cancel, adopt);
buttons.setWidthFull();
buttons.setJustifyContentMode(com.vaadin.flow.component.orderedlayout.FlexComponent.JustifyContentMode.END);
dialog.add(text, buttons);
dialog.open();
} else {
completeLogin(ui, password, niederlassungInfo, false);
}
});
});
// Button-Styling
loginButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_LARGE); loginButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_LARGE);
loginButton.setWidthFull(); loginButton.setWidthFull();
loginButton.getStyle().set("margin-top", "8px") loginButton.getStyle().set("margin-top", "8px")
.set("background", "linear-gradient(135deg, #667eea 0%, #764ba2 100%)").set("border", "none") .set("background", "linear-gradient(135deg, #667eea 0%, #764ba2 100%)").set("border", "none")
.set("font-weight", "600"); .set("font-weight", "600");
// Eingabefelder zum Form-Layout hinzufügen formLayout.add(loginButton);
formLayout.add(passwordField, niederlassungSelect, loginButton); loginContainer.add(icon, title, formLayout);
// Alle Komponenten zum Login-Container hinzufügen
loginContainer.add(icon, title, subtitle, formLayout);
// Login-Container zur Hauptansicht hinzufügen
add(loginContainer); add(loginContainer);
if (MainLayout.instance != null) { if (MainLayout.instance != null) {
@@ -149,78 +70,39 @@ public class LoginView extends VerticalLayout {
} }
} }
private boolean isContainerRunning(de.assecutor.emulatorstation.pojo.NiederlassungInfo info) { @Override
if (info == null) { public void afterNavigation(AfterNavigationEvent event) {
return false; getUI().ifPresent(ui -> {
} if (!ssoConfiguration.isEnabled()) {
final String srv = "172.16.0.158"; // Fallback wie in MainView ui.navigate(SimCardConfigurationView.class);
final String name = "android-container-" + info.name(); return;
try {
var client = java.net.http.HttpClient.newHttpClient();
var request = java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create("http://" + srv + ":2375/containers/" + name + "/json"))
.GET()
.build();
var response = client.send(request, java.net.http.HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
var body = response.body();
return body != null && body.contains("\"Running\":true");
}
} catch (Exception e) {
logger.warn("Fehler beim Pruefen des Container-Status fuer '{}': {}", name, e.toString());
}
return false;
} }
private void completeLogin(com.vaadin.flow.component.UI ui, String password, if (authenticationContext.isAuthenticated()) {
de.assecutor.emulatorstation.pojo.NiederlassungInfo niederlassungInfo, if (loginSessionService.hasApplicationSession(ui.getSession())
boolean adoptRunningContainer) { && loginSessionService.hasCompletedSimConfiguration(ui.getSession())) {
String niederlassung = niederlassungInfo.name(); ui.navigate(MainView.class);
} else {
String sessionId = ui.getSession().getSession().getId(); ui.navigate(SimCardConfigurationView.class);
// Session niemals ablaufen lassen }
ui.getSession().getSession().setMaxInactiveInterval(-1); return;
// HttpSession/WrappedSession im Registry merken, um später invalidieren zu können
Application.sessionsById.put(sessionId, ui.getSession().getSession());
// Session registrieren
Application.activeSessions.put(sessionId, niederlassung);
Application.activeNiederlassungen.put(niederlassung, sessionId);
// Log that Niederlassung is now blocked
logger.info("Niederlassung '{}' wurde gesperrt fuer Session {}", niederlassung, sessionId);
logger.info("Aktive Niederlassungen: {}", Application.activeNiederlassungen.keySet());
// Spring Security Authentifizierung setzen
var authorities = java.util.List.of(new org.springframework.security.core.authority.SimpleGrantedAuthority("ROLE_USER"));
var authentication = new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(
Application.SINGLE_USERNAME, password, authorities);
org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(authentication);
// Spring Security Kontext explizit in der HTTP-Session speichern, damit Reload eingeloggt bleibt
ui.getSession().getSession().setAttribute(
org.springframework.security.web.context.HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
org.springframework.security.core.context.SecurityContextHolder.getContext());
// Session-Daten setzen
ui.getSession().setAttribute("user", Application.SINGLE_USERNAME);
ui.getSession().setAttribute("username", Application.SINGLE_USERNAME);
ui.getSession().setAttribute("niederlassung", niederlassungInfo);
ui.getSession().setAttribute("sessionId", sessionId);
if (adoptRunningContainer) {
// UI soll direkt in gestarteten Zustand gehen
ui.getSession().setAttribute("emulatorStarted", true);
} }
logger.info("Login erfolgreich - Session-Daten gesetzt:"); if (event.getLocation().getQueryParameters().getParameters().containsKey("error")) {
logger.info("Username: {}", Application.SINGLE_USERNAME); Notification.show("Die Microsoft-Anmeldung konnte nicht abgeschlossen werden.", 5000,
logger.info("Niederlassung: {}", niederlassungInfo.name()); Notification.Position.MIDDLE);
logger.info("SessionId: {}", sessionId); }
logger.info("Aktive Sessions: {}/{}", Application.activeSessions.size(), Application.MAX_ACTIVE_SESSIONS); });
ui.navigate("main");
} }
private void startLogin() {
getUI().ifPresent(ui -> {
if (!ssoConfiguration.isEnabled() || authenticationContext.isAuthenticated()) {
ui.navigate(SimCardConfigurationView.class);
return;
}
ui.getPage().setLocation("/oauth2/authorization/azure");
});
}
} }

View File

@@ -16,14 +16,14 @@ import com.vaadin.flow.component.sidenav.SideNav;
import com.vaadin.flow.component.sidenav.SideNavItem; import com.vaadin.flow.component.sidenav.SideNavItem;
import com.vaadin.flow.router.Layout; import com.vaadin.flow.router.Layout;
import com.vaadin.flow.server.VaadinSession; import com.vaadin.flow.server.VaadinSession;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import com.vaadin.flow.server.menu.MenuConfiguration; import com.vaadin.flow.server.menu.MenuConfiguration;
import com.vaadin.flow.server.menu.MenuEntry; import com.vaadin.flow.server.menu.MenuEntry;
import jakarta.annotation.security.PermitAll;
import static com.vaadin.flow.theme.lumo.LumoUtility.*; import static com.vaadin.flow.theme.lumo.LumoUtility.*;
@Layout @Layout
@PermitAll // When security is enabled, allow all authenticated users @AnonymousAllowed
public final class MainLayout extends AppLayout { public final class MainLayout extends AppLayout {
public static MainLayout instance = null; public static MainLayout instance = null;
@@ -54,7 +54,6 @@ public final class MainLayout extends AppLayout {
} }
private Div createHeader() { private Div createHeader() {
// TODO Replace with real application logo and name
var appLogo = VaadinIcon.CUBES.create(); var appLogo = VaadinIcon.CUBES.create();
appLogo.addClassNames(TextColor.PRIMARY, IconSize.LARGE); appLogo.addClassNames(TextColor.PRIMARY, IconSize.LARGE);
@@ -82,7 +81,6 @@ public final class MainLayout extends AppLayout {
} }
private Component createUserMenu() { private Component createUserMenu() {
// TODO Replace with real user information and actions
var avatar = new Avatar("John Smith"); var avatar = new Avatar("John Smith");
avatar.addThemeVariants(AvatarVariant.LUMO_XSMALL); avatar.addThemeVariants(AvatarVariant.LUMO_XSMALL);
avatar.addClassNames(Margin.Right.SMALL); avatar.addClassNames(Margin.Right.SMALL);

View File

@@ -22,12 +22,15 @@ import com.vaadin.flow.component.html.Main;
import com.vaadin.flow.router.Route; import com.vaadin.flow.router.Route;
import com.vaadin.flow.router.PreserveOnRefresh; import com.vaadin.flow.router.PreserveOnRefresh;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import com.vaadin.flow.server.VaadinSession; import com.vaadin.flow.server.VaadinSession;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import com.vaadin.flow.spring.security.AuthenticationContext;
import de.assecutor.emulatorstation.pojo.ExecResponse; import de.assecutor.emulatorstation.pojo.ExecResponse;
import de.assecutor.emulatorstation.pojo.NiederlassungInfo; import de.assecutor.emulatorstation.pojo.NiederlassungInfo;
import de.assecutor.emulatorstation.Application; import de.assecutor.emulatorstation.Application;
import jakarta.annotation.security.PermitAll; import de.assecutor.emulatorstation.base.ui.view.security.EmulatorServerConfiguration;
import de.assecutor.emulatorstation.base.ui.view.security.LoginSessionService;
import de.assecutor.emulatorstation.base.ui.view.security.SsoConfiguration;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -37,13 +40,18 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@PermitAll // When security is enabled, allow all authenticated users @AnonymousAllowed
@Route("main") @Route("main")
@PreserveOnRefresh @PreserveOnRefresh
@AnonymousAllowed
public final class MainView extends Main implements BeforeEnterObserver { public final class MainView extends Main implements BeforeEnterObserver {
private static final Logger logger = LoggerFactory.getLogger(MainView.class); private static final Logger logger = LoggerFactory.getLogger(MainView.class);
private final AuthenticationContext authenticationContext;
private final EmulatorServerConfiguration emulatorServerConfiguration;
private final LoginSessionService loginSessionService;
private final SsoConfiguration ssoConfiguration;
private String username; private String username;
private NiederlassungInfo niederlassung; private NiederlassungInfo niederlassung;
private String server; private String server;
@@ -55,7 +63,13 @@ public final class MainView extends Main implements BeforeEnterObserver {
private final ExecutorService executor = Executors.newSingleThreadExecutor(); private final ExecutorService executor = Executors.newSingleThreadExecutor();
MainView() { MainView(AuthenticationContext authenticationContext, EmulatorServerConfiguration emulatorServerConfiguration,
LoginSessionService loginSessionService, SsoConfiguration ssoConfiguration) {
this.authenticationContext = authenticationContext;
this.emulatorServerConfiguration = emulatorServerConfiguration;
this.loginSessionService = loginSessionService;
this.ssoConfiguration = ssoConfiguration;
setSizeFull(); setSizeFull();
getStyle().set("background", "linear-gradient(135deg, #667eea 0%, #764ba2 100%)").set("min-height", "100vh") getStyle().set("background", "linear-gradient(135deg, #667eea 0%, #764ba2 100%)").set("min-height", "100vh")
.set("display", "flex").set("flex-direction", "column").set("align-items", "center") .set("display", "flex").set("flex-direction", "column").set("align-items", "center")
@@ -66,7 +80,6 @@ public final class MainView extends Main implements BeforeEnterObserver {
contentContainer.getStyle().set("width", "95%").set("height", "95%").set("display", "flex") contentContainer.getStyle().set("width", "95%").set("height", "95%").set("display", "flex")
.set("flex-direction", "column").set("box-sizing", "border-box"); .set("flex-direction", "column").set("box-sizing", "border-box");
setupWelcomeMessage(contentContainer); setupWelcomeMessage(contentContainer);
setupEmulatorContainer(contentContainer); setupEmulatorContainer(contentContainer);
setupButtonLayout(contentContainer); setupButtonLayout(contentContainer);
@@ -148,8 +161,6 @@ public final class MainView extends Main implements BeforeEnterObserver {
} }
} }
private void setupWelcomeMessage(Div container) { private void setupWelcomeMessage(Div container) {
welcomeMessage.addClassName("welcome-panel"); welcomeMessage.addClassName("welcome-panel");
welcomeMessage.getStyle().set("display", "flex").set("flex-direction", "column").set("align-items", "center") welcomeMessage.getStyle().set("display", "flex").set("flex-direction", "column").set("align-items", "center")
@@ -226,7 +237,6 @@ public final class MainView extends Main implements BeforeEnterObserver {
var vaadinSession = currentUI.get().getSession(); var vaadinSession = currentUI.get().getSession();
username = (String) vaadinSession.getAttribute("username"); username = (String) vaadinSession.getAttribute("username");
niederlassung = (NiederlassungInfo) vaadinSession.getAttribute("niederlassung"); niederlassung = (NiederlassungInfo) vaadinSession.getAttribute("niederlassung");
server = (String) vaadinSession.getAttribute("server");
// Fallback: Nach Reload Niederlassung anhand der aktiven Sessions rekonstruieren // Fallback: Nach Reload Niederlassung anhand der aktiven Sessions rekonstruieren
if (niederlassung == null) { if (niederlassung == null) {
@@ -254,9 +264,7 @@ public final class MainView extends Main implements BeforeEnterObserver {
logger.error("UI nicht verfügbar - kann Session-Daten nicht laden"); logger.error("UI nicht verfügbar - kann Session-Daten nicht laden");
} }
if (server == null) { server = emulatorServerConfiguration.getServerIp();
server = "172.16.0.158";
}
logger.info("MainView Session-Daten geladen:"); logger.info("MainView Session-Daten geladen:");
logger.info("Username: {}", username); logger.info("Username: {}", username);
@@ -267,7 +275,8 @@ public final class MainView extends Main implements BeforeEnterObserver {
private void restoreUIStateIfNeeded() { private void restoreUIStateIfNeeded() {
var uiOpt = getUI(); var uiOpt = getUI();
if (uiOpt.isEmpty()) return; if (uiOpt.isEmpty())
return;
var session = uiOpt.get().getSession(); var session = uiOpt.get().getSession();
boolean emulatorStarted = Boolean.TRUE.equals(session.getAttribute("emulatorStarted")); boolean emulatorStarted = Boolean.TRUE.equals(session.getAttribute("emulatorStarted"));
@@ -286,7 +295,25 @@ public final class MainView extends Main implements BeforeEnterObserver {
@Override @Override
public void beforeEnter(BeforeEnterEvent beforeEnterEvent) { public void beforeEnter(BeforeEnterEvent beforeEnterEvent) {
// Kein Redirect mehr zur Login-Seite bei fehlender Session VaadinSession session = VaadinSession.getCurrent();
if (!ssoConfiguration.isEnabled()) {
if (!loginSessionService.hasApplicationSession(session)
|| !loginSessionService.hasCompletedSimConfiguration(session)) {
beforeEnterEvent.rerouteTo(SimCardConfigurationView.class);
}
return;
}
if (!authenticationContext.isAuthenticated()) {
beforeEnterEvent.rerouteTo(LoginView.class);
return;
}
if (!loginSessionService.hasApplicationSession(session)
|| !loginSessionService.hasCompletedSimConfiguration(session)) {
beforeEnterEvent.rerouteTo(SimCardConfigurationView.class);
}
} }
private void startup() { private void startup() {
@@ -310,6 +337,8 @@ public final class MainView extends Main implements BeforeEnterObserver {
downloadApp(); downloadApp();
installApp(); installApp();
startApp();
} }
} catch (Exception ex) { } catch (Exception ex) {
// Logging, Fehlerbehandlung … // Logging, Fehlerbehandlung …
@@ -390,6 +419,83 @@ public final class MainView extends Main implements BeforeEnterObserver {
} }
} }
private void startApp() {
String packageName = listThirdPartyPackage();
if (packageName == null || packageName.isBlank()) {
logger.error("Konnte Package-Namen der installierten App nicht ermitteln - App wird nicht gestartet");
return;
}
logger.info("Starte App mit Package-Name: {}", packageName);
HttpClient client = HttpClient.newHttpClient();
String jsonPayload = """
{
"Cmd": ["adb", "shell", "monkey", "-p", "%s", "-c", "android.intent.category.LAUNCHER", "1"],
"AttachStdout": true,
"AttachStderr": true
}""".formatted(packageName);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(
"http://" + server + ":2375/containers/android-container-" + niederlassung.name() + "/exec"))
.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
try {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
logger.info("HTTP-Response-Code: {}", response.statusCode());
logger.info("Response-Body: {}", response.body());
var execId = ExecResponse.parse(response.body());
exec(execId);
} catch (Exception e) {
logger.error("Fehler beim Starten der App", e);
}
}
private String listThirdPartyPackage() {
HttpClient client = HttpClient.newHttpClient();
String jsonPayload = """
{
"Cmd": ["adb", "shell", "pm", "list", "packages", "-3"],
"AttachStdout": true,
"AttachStderr": true
}""";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(
"http://" + server + ":2375/containers/android-container-" + niederlassung.name() + "/exec"))
.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
try {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
logger.info("HTTP-Response-Code: {}", response.statusCode());
logger.info("Response-Body: {}", response.body());
var execId = ExecResponse.parse(response.body());
var execResponse = exec(execId);
return extractFirstPackageName(execResponse);
} catch (Exception e) {
logger.error("Fehler beim Ermitteln des Package-Namens", e);
return null;
}
}
private String extractFirstPackageName(String body) {
if (body == null) {
return null;
}
Matcher matcher = Pattern.compile("package:([A-Za-z0-9_.]+)").matcher(body);
if (matcher.find()) {
return matcher.group(1);
}
return null;
}
private void installApp() { private void installApp() {
HttpClient client = HttpClient.newHttpClient(); HttpClient client = HttpClient.newHttpClient();
@@ -609,6 +715,7 @@ public final class MainView extends Main implements BeforeEnterObserver {
logger.error("Fehler beim HTTP-Request", e); logger.error("Fehler beim HTTP-Request", e);
} }
} }
private String buildContainerName(NiederlassungInfo info) { private String buildContainerName(NiederlassungInfo info) {
return "android-container-" + info.name(); return "android-container-" + info.name();
} }
@@ -618,14 +725,12 @@ public final class MainView extends Main implements BeforeEnterObserver {
logger.warn("isContainerStarted: Niederlassung ist null"); logger.warn("isContainerStarted: Niederlassung ist null");
return false; return false;
} }
String srv = (this.server != null ? this.server : "172.16.0.158"); String srv = this.server != null ? this.server : emulatorServerConfiguration.getServerIp();
String name = buildContainerName(info); String name = buildContainerName(info);
try { try {
HttpClient client = HttpClient.newHttpClient(); HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://" + srv + ":2375/containers/" + name + "/json")) .uri(URI.create("http://" + srv + ":2375/containers/" + name + "/json")).GET().build();
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
int status = response.statusCode(); int status = response.statusCode();
logger.info("Inspect-Container '{}': status={} ", name, status); logger.info("Inspect-Container '{}': status={} ", name, status);
@@ -645,7 +750,6 @@ public final class MainView extends Main implements BeforeEnterObserver {
return false; return false;
} }
private Dialog showWaitDialog(String message) { private Dialog showWaitDialog(String message) {
Dialog dialog = new Dialog(); Dialog dialog = new Dialog();
dialog.setModal(true); dialog.setModal(true);
@@ -735,45 +839,12 @@ public final class MainView extends Main implements BeforeEnterObserver {
logger.info("Starte Logout-Cleanup nach Shutdown"); logger.info("Starte Logout-Cleanup nach Shutdown");
dialog.close(); dialog.close();
// Session cleanup erst nach Shutdown loginSessionService.cleanupApplicationSession(ui.getSession());
String sessionId = (String) ui.getSession().getAttribute("sessionId"); if (ssoConfiguration.isEnabled()) {
if (sessionId != null) { authenticationContext.logout();
Application.activeSessions.remove(sessionId); } else {
logger.info("Session {} aus aktiven Sessions entfernt", sessionId); ui.navigate(SimCardConfigurationView.class);
Application.sessionsById.remove(sessionId);
} }
if (niederlassung != null) {
Application.activeNiederlassungen.remove(niederlassung.name());
logger.info("Niederlassung {} aus aktiven Niederlassungen entfernt",
niederlassung.name());
}
logger.info("Aktive Sessions nach Logout: {}/{}", Application.activeSessions.size(),
Application.MAX_ACTIVE_SESSIONS);
// 1) Client-seitigen Redirect sofort ausführen (wird beim Client direkt verarbeitet)
var httpSession = ui.getSession().getSession();
var vaadinSession = ui.getSession();
ui.getPage().executeJs("window.location.replace($0);", "login");
// 2) Session-Schließung leicht verzögert im Hintergrund, damit der Redirect sicher ankommt
executor.submit(() -> {
try {
Thread.sleep(300); // kurze Verzögerung reicht i.d.R.
} catch (InterruptedException ignored) {
}
try {
vaadinSession.close();
} catch (Exception e) {
logger.warn("Fehler beim Schließen der VaadinSession nach Redirect", e);
}
try {
httpSession.invalidate();
} catch (Exception e) {
logger.warn("Fehler beim Invalidieren der HttpSession nach Redirect", e);
}
logger.info("Session nach Redirect geschlossen/invalidiert");
});
} catch (Exception ex) { } catch (Exception ex) {
logger.error("Fehler beim Logout UI-Update", ex); logger.error("Fehler beim Logout UI-Update", ex);

View File

@@ -3,12 +3,21 @@ package de.assecutor.emulatorstation.base.ui.view;
import com.vaadin.flow.router.BeforeEnterEvent; import com.vaadin.flow.router.BeforeEnterEvent;
import com.vaadin.flow.router.BeforeEnterObserver; import com.vaadin.flow.router.BeforeEnterObserver;
import com.vaadin.flow.router.Route; import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import de.assecutor.emulatorstation.base.ui.view.security.SsoConfiguration;
@Route("") @Route("")
@AnonymousAllowed
public class RootView implements BeforeEnterObserver { public class RootView implements BeforeEnterObserver {
private final SsoConfiguration ssoConfiguration;
public RootView(SsoConfiguration ssoConfiguration) {
this.ssoConfiguration = ssoConfiguration;
}
@Override @Override
public void beforeEnter(BeforeEnterEvent event) { public void beforeEnter(BeforeEnterEvent event) {
event.rerouteTo("main"); event.rerouteTo(ssoConfiguration.isEnabled() ? "login" : "sim-config");
} }
} }

View File

@@ -0,0 +1,326 @@
package de.assecutor.emulatorstation.base.ui.view;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.data.renderer.ComponentRenderer;
import com.vaadin.flow.router.BeforeEnterEvent;
import com.vaadin.flow.router.BeforeEnterObserver;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.VaadinSession;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import com.vaadin.flow.spring.security.AuthenticationContext;
import de.assecutor.emulatorstation.base.domain.EmulatorContainerService;
import de.assecutor.emulatorstation.base.domain.NiederlassungResolver;
import de.assecutor.emulatorstation.base.domain.SimCardAssignmentService;
import de.assecutor.emulatorstation.base.ui.view.security.LoginSessionService;
import de.assecutor.emulatorstation.base.ui.view.security.SsoConfiguration;
import de.assecutor.emulatorstation.pojo.CourierInfo;
import de.assecutor.emulatorstation.pojo.NiederlassungInfo;
import de.assecutor.emulatorstation.pojo.SimCardInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Route("sim-config")
@PageTitle("SIM-Konfiguration")
@AnonymousAllowed
public class SimCardConfigurationView extends VerticalLayout implements BeforeEnterObserver {
private static final Logger logger = LoggerFactory.getLogger(SimCardConfigurationView.class);
private final AuthenticationContext authenticationContext;
private final LoginSessionService loginSessionService;
private final EmulatorContainerService emulatorContainerService;
private final NiederlassungResolver niederlassungResolver;
private final SimCardAssignmentService simCardAssignmentService;
private final SsoConfiguration ssoConfiguration;
private final ComboBox<CourierInfo> courierSelect = new ComboBox<>("Kurier");
private final ComboBox<SimCardInfo> simCardSelect = new ComboBox<>("SIM-Karte");
private final Paragraph statusText = new Paragraph("Lade Kuriere und SIM-Karten...");
private final Button assignButton;
private final Map<Long, String> courierSidByUserId = new HashMap<>();
public SimCardConfigurationView(AuthenticationContext authenticationContext,
LoginSessionService loginSessionService, EmulatorContainerService emulatorContainerService,
NiederlassungResolver niederlassungResolver, SimCardAssignmentService simCardAssignmentService,
SsoConfiguration ssoConfiguration) {
this.authenticationContext = authenticationContext;
this.loginSessionService = loginSessionService;
this.emulatorContainerService = emulatorContainerService;
this.niederlassungResolver = niederlassungResolver;
this.simCardAssignmentService = simCardAssignmentService;
this.ssoConfiguration = ssoConfiguration;
setSizeFull();
setAlignItems(Alignment.CENTER);
setJustifyContentMode(JustifyContentMode.CENTER);
getStyle().set("background", "linear-gradient(135deg, #667eea 0%, #764ba2 100%)").set("min-height", "100vh")
.set("padding", "20px");
Div container = new Div();
container.getStyle().set("background-color", "#ffffff").set("border-radius", "12px")
.set("box-shadow", "0 10px 30px rgba(0,0,0,0.2)").set("padding", "40px").set("max-width", "560px")
.set("width", "100%");
Icon icon = new Icon(VaadinIcon.MOBILE);
icon.setSize("56px");
icon.getStyle().set("color", "#667eea").set("margin-bottom", "18px");
H1 title = new H1("SIM-Karte zuordnen");
title.getStyle().set("margin", "0 0 8px 0").set("color", "#333333").set("font-size", "2rem").set("font-weight",
"600");
Paragraph subtitle = new Paragraph(
"Wählen Sie einen Kurier und eine SIM-Karte. Danach gelangen Sie zur Hauptseite.");
subtitle.getStyle().set("margin", "0 0 8px 0").set("color", "#666666").set("font-size", "0.95rem");
courierSelect.setWidthFull();
courierSelect.setPlaceholder("Kurier auswählen");
courierSelect.setItemLabelGenerator(CourierInfo::displayLabel);
simCardSelect.setWidthFull();
simCardSelect.setPlaceholder("SIM-Karte auswählen");
simCardSelect.setItemLabelGenerator(SimCardInfo::displayLabel);
simCardSelect.setRenderer(new ComponentRenderer<>(simCard -> {
HorizontalLayout row = new HorizontalLayout();
row.setAlignItems(Alignment.CENTER);
row.setSpacing(true);
row.setPadding(false);
row.setWidthFull();
row.setJustifyContentMode(JustifyContentMode.BETWEEN);
Span label = new Span(simCard.displayLabel());
row.add(label);
if (isSimCardOccupied(simCard)) {
String courierSid = courierSidByUserId.getOrDefault(simCard.userId(),
String.valueOf(simCard.userId()));
Span badge = new Span("Belegt durch " + courierSid);
badge.getElement().getThemeList().add("badge error small");
Button releaseButton = new Button(new Icon(VaadinIcon.UNLINK));
releaseButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE, ButtonVariant.LUMO_SMALL,
ButtonVariant.LUMO_ERROR);
releaseButton.getElement().setAttribute("title", "Zuordnung aufheben");
releaseButton.addClickListener(event -> confirmReleaseSimCard(simCard));
releaseButton.getElement().addEventListener("click", event -> {
}).addEventData("event.stopPropagation()");
HorizontalLayout actionsLayout = new HorizontalLayout(badge, releaseButton);
actionsLayout.setAlignItems(Alignment.CENTER);
actionsLayout.setSpacing(true);
actionsLayout.setPadding(false);
row.add(actionsLayout);
}
return row;
}));
assignButton = new Button("Weiter", new Icon(VaadinIcon.CHECK), event -> assignSimCard());
assignButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
assignButton.setEnabled(false);
HorizontalLayout actions = new HorizontalLayout(assignButton);
actions.setSpacing(true);
statusText.getStyle().set("margin", "18px 0 0 0").set("color", "#666666").set("font-size", "0.9rem");
VerticalLayout form = new VerticalLayout(icon, title, subtitle, courierSelect, simCardSelect, actions,
statusText);
form.setSpacing(true);
form.setPadding(false);
form.setWidthFull();
container.add(form);
add(container);
addAttachListener(event -> loadOptions());
if (MainLayout.instance != null) {
MainLayout.instance.setDrawerOpened(false);
}
}
@Override
public void beforeEnter(BeforeEnterEvent event) {
VaadinSession session = VaadinSession.getCurrent();
if (ssoConfiguration.isEnabled() && !authenticationContext.isAuthenticated()) {
event.rerouteTo(LoginView.class);
return;
}
if (loginSessionService.hasApplicationSession(session)
&& loginSessionService.hasCompletedSimConfiguration(session)) {
event.rerouteTo(MainView.class);
}
}
private void loadOptions() {
try {
List<CourierInfo> couriers = simCardAssignmentService.fetchCouriers();
List<SimCardInfo> simCards = simCardAssignmentService.fetchSimCards();
courierSidByUserId.clear();
for (CourierInfo courier : couriers) {
courierSidByUserId.putIfAbsent(courier.userId(), courier.courierSid());
}
courierSelect.setItems(couriers);
simCardSelect.setItems(simCards);
courierSelect.clear();
simCardSelect.clear();
boolean hasData = !couriers.isEmpty() && !simCards.isEmpty();
assignButton.setEnabled(hasData);
if (hasData) {
statusText.setText("Es stehen " + couriers.size() + " Kuriere und " + simCards.size()
+ " SIM-Karten zur Auswahl.");
} else {
statusText.setText("Es wurden keine Kuriere oder SIM-Karten gefunden.");
}
} catch (Exception ex) {
assignButton.setEnabled(false);
showError(ex.getMessage());
}
}
private void assignSimCard() {
CourierInfo courier = courierSelect.getValue();
SimCardInfo simCard = simCardSelect.getValue();
if (courier == null || simCard == null) {
Notification.show("Bitte wählen Sie einen Kurier und eine SIM-Karte aus.", 3000,
Notification.Position.MIDDLE);
return;
}
NiederlassungInfo niederlassungInfo = niederlassungResolver.resolveFromSimCard(simCard);
if (niederlassungInfo == null) {
showError("SIM-Karte '" + simCard.secretValue() + "' definiert keine gültige Niederlassung.");
return;
}
getUI().ifPresent(ui -> {
if (emulatorContainerService.isContainerRunning(niederlassungInfo)) {
confirmContainerAdoption(ui, courier, simCard, niederlassungInfo);
} else {
continueAssignment(ui, courier, simCard, niederlassungInfo, false);
}
});
}
private void confirmContainerAdoption(com.vaadin.flow.component.UI ui, CourierInfo courier, SimCardInfo simCard,
NiederlassungInfo niederlassungInfo) {
Dialog dialog = new Dialog();
dialog.setHeaderTitle("Container läuft bereits");
Paragraph text = new Paragraph("Für '" + niederlassungInfo.name()
+ "' läuft bereits ein Emulator-Container. Möchten Sie ihn für diese Session übernehmen?");
Button adoptButton = new Button("Übernehmen", event -> {
dialog.close();
continueAssignment(ui, courier, simCard, niederlassungInfo, true);
});
adoptButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button cancelButton = new Button("Abbrechen", event -> dialog.close());
HorizontalLayout buttons = new HorizontalLayout(cancelButton, adoptButton);
buttons.setWidthFull();
buttons.setJustifyContentMode(FlexComponent.JustifyContentMode.END);
dialog.add(text, buttons);
dialog.open();
}
private void continueAssignment(com.vaadin.flow.component.UI ui, CourierInfo courier, SimCardInfo simCard,
NiederlassungInfo niederlassungInfo, boolean adoptRunningContainer) {
assignButton.setEnabled(false);
statusText.setText("Zuordnung wird gespeichert...");
String error = loginSessionService.initializeApplicationSession(ui, niederlassungInfo, adoptRunningContainer);
if (error != null) {
assignButton.setEnabled(true);
showError(error);
return;
}
try {
logger.info("Sende SIM-Zuordnung: Kurier='{}', SIM-Karte='{}'", courier.displayLabel(),
simCard.displayLabel());
simCardAssignmentService.assignSimCard(courier, simCard);
loginSessionService.markSimConfigurationCompleted(ui.getSession());
statusText.setText("SIM-Karte wurde erfolgreich zugeordnet.");
Notification.show("SIM-Karte erfolgreich zugeordnet.", 3000, Notification.Position.MIDDLE);
ui.navigate(MainView.class);
} catch (Exception ex) {
loginSessionService.cleanupApplicationSession(ui.getSession());
assignButton.setEnabled(true);
showError(ex.getMessage());
}
}
private void showError(String message) {
statusText.setText(message);
Notification.show(message, 5000, Notification.Position.MIDDLE);
}
private void confirmReleaseSimCard(SimCardInfo simCard) {
simCardSelect.getElement().executeJs("this.close()");
Dialog dialog = new Dialog();
dialog.setHeaderTitle("Zuordnung aufheben");
Paragraph text = new Paragraph(
"Möchten Sie die Zuordnung der SIM-Karte '" + simCard.displayLabel() + "' wirklich aufheben?");
Button confirmButton = new Button("Aufheben", event -> {
dialog.close();
releaseSimCard(simCard);
});
confirmButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_ERROR);
Button cancelButton = new Button("Abbrechen", event -> dialog.close());
HorizontalLayout buttons = new HorizontalLayout(cancelButton, confirmButton);
buttons.setWidthFull();
buttons.setJustifyContentMode(FlexComponent.JustifyContentMode.END);
dialog.add(text, buttons);
dialog.open();
}
private void releaseSimCard(SimCardInfo simCard) {
try {
simCardAssignmentService.releaseSimCard(simCard);
Notification.show("Zuordnung der SIM-Karte '" + simCard.displayLabel() + "' wurde aufgehoben.", 3000,
Notification.Position.MIDDLE);
loadOptions();
} catch (Exception ex) {
showError(ex.getMessage());
}
}
private static boolean isSimCardOccupied(SimCardInfo simCard) {
Long userId = simCard.userId();
return userId != null && userId != 0L;
}
}

View File

@@ -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));
}
}
}

View File

@@ -0,0 +1,18 @@
package de.assecutor.emulatorstation.base.ui.view.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class EmulatorServerConfiguration {
private final String serverIp;
public EmulatorServerConfiguration(@Value("${app.emulator.server-ip:172.16.0.158}") String serverIp) {
this.serverIp = serverIp;
}
public String getServerIp() {
return serverIp;
}
}

View File

@@ -0,0 +1,183 @@
package de.assecutor.emulatorstation.base.ui.view.security;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.server.VaadinSession;
import com.vaadin.flow.server.WrappedSession;
import de.assecutor.emulatorstation.Application;
import de.assecutor.emulatorstation.pojo.NiederlassungInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Service;
@Service
public class LoginSessionService {
private static final Logger logger = LoggerFactory.getLogger(LoginSessionService.class);
private static final String USER_ATTRIBUTE = "user";
private static final String USERNAME_ATTRIBUTE = "username";
private static final String NIEDERLASSUNG_ATTRIBUTE = "niederlassung";
private static final String SESSION_ID_ATTRIBUTE = "sessionId";
private static final String EMULATOR_STARTED_ATTRIBUTE = "emulatorStarted";
private static final String SIM_CONFIGURATION_COMPLETED_ATTRIBUTE = "simConfigurationCompleted";
public boolean hasApplicationSession(VaadinSession session) {
if (session == null) {
return false;
}
if (session.getAttribute(NIEDERLASSUNG_ATTRIBUTE) instanceof NiederlassungInfo) {
return true;
}
WrappedSession wrappedSession = getWrappedSession(session);
return wrappedSession != null && Application.activeSessions.containsKey(wrappedSession.getId());
}
public boolean hasCompletedSimConfiguration(VaadinSession session) {
return session != null && Boolean.TRUE.equals(session.getAttribute(SIM_CONFIGURATION_COMPLETED_ATTRIBUTE));
}
public void markSimConfigurationCompleted(VaadinSession session) {
if (session == null) {
return;
}
session.setAttribute(SIM_CONFIGURATION_COMPLETED_ATTRIBUTE, true);
}
public String initializeApplicationSession(UI ui, NiederlassungInfo niederlassungInfo,
boolean adoptRunningContainer) {
if (ui == null) {
return "UI ist nicht verfügbar.";
}
VaadinSession vaadinSession = ui.getSession();
WrappedSession wrappedSession = getWrappedSession(vaadinSession);
if (wrappedSession == null) {
return "Keine HTTP-Session verfügbar.";
}
if (niederlassungInfo == null) {
return "Die ermittelte Niederlassung ist ungültig.";
}
String sessionId = wrappedSession.getId();
if (!Application.activeSessions.containsKey(sessionId)
&& Application.activeSessions.size() >= Application.MAX_ACTIVE_SESSIONS) {
return "Maximale Anzahl von " + Application.MAX_ACTIVE_SESSIONS
+ " gleichzeitigen Anmeldungen erreicht. Bitte versuchen Sie es später erneut.";
}
String username = resolveCurrentUsername();
wrappedSession.setMaxInactiveInterval(-1);
Application.sessionsById.put(sessionId, wrappedSession);
Application.activeSessions.put(sessionId, niederlassungInfo.name());
Application.activeNiederlassungen.put(niederlassungInfo.name(), sessionId);
vaadinSession.setAttribute(USER_ATTRIBUTE, username);
vaadinSession.setAttribute(USERNAME_ATTRIBUTE, username);
vaadinSession.setAttribute(NIEDERLASSUNG_ATTRIBUTE, niederlassungInfo);
vaadinSession.setAttribute(SESSION_ID_ATTRIBUTE, sessionId);
if (adoptRunningContainer) {
vaadinSession.setAttribute(EMULATOR_STARTED_ATTRIBUTE, true);
} else {
vaadinSession.setAttribute(EMULATOR_STARTED_ATTRIBUTE, null);
}
vaadinSession.setAttribute(SIM_CONFIGURATION_COMPLETED_ATTRIBUTE, null);
logger.info("Login erfolgreich - Session-Daten gesetzt:");
logger.info("Username: {}", username);
logger.info("Niederlassung: {}", niederlassungInfo.name());
logger.info("SessionId: {}", sessionId);
logger.info("Aktive Sessions: {}/{}", Application.activeSessions.size(), Application.MAX_ACTIVE_SESSIONS);
return null;
}
public void cleanupApplicationSession(VaadinSession session) {
if (session == null) {
return;
}
WrappedSession wrappedSession = getWrappedSession(session);
String sessionId = wrappedSession != null ? wrappedSession.getId() : null;
NiederlassungInfo niederlassungInfo = (NiederlassungInfo) session.getAttribute(NIEDERLASSUNG_ATTRIBUTE);
cleanupApplicationSession(sessionId, niederlassungInfo != null ? niederlassungInfo.name() : null);
session.setAttribute(USER_ATTRIBUTE, null);
session.setAttribute(USERNAME_ATTRIBUTE, null);
session.setAttribute(NIEDERLASSUNG_ATTRIBUTE, null);
session.setAttribute(SESSION_ID_ATTRIBUTE, null);
session.setAttribute(EMULATOR_STARTED_ATTRIBUTE, null);
session.setAttribute(SIM_CONFIGURATION_COMPLETED_ATTRIBUTE, null);
}
public void cleanupApplicationSession(String sessionId) {
cleanupApplicationSession(sessionId, null);
}
private void cleanupApplicationSession(String sessionId, String niederlassungName) {
if (sessionId == null || sessionId.isBlank()) {
return;
}
String activeNiederlassung = niederlassungName != null
? niederlassungName
: Application.activeSessions.get(sessionId);
Application.activeSessions.remove(sessionId);
Application.sessionsById.remove(sessionId);
if (activeNiederlassung != null) {
Application.activeNiederlassungen.remove(activeNiederlassung);
logger.info("Niederlassung {} aus aktiven Niederlassungen entfernt", activeNiederlassung);
}
logger.info("Session {} aus aktiven Sessions entfernt", sessionId);
logger.info("Aktive Sessions nach Cleanup: {}/{}", Application.activeSessions.size(),
Application.MAX_ACTIVE_SESSIONS);
}
private WrappedSession getWrappedSession(VaadinSession session) {
return session != null ? session.getSession() : null;
}
private boolean isAuthenticated(Authentication authentication) {
return authentication != null && authentication.isAuthenticated()
&& !(authentication instanceof AnonymousAuthenticationToken);
}
private String resolveCurrentUsername() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (isAuthenticated(authentication)) {
return resolveUsername(authentication);
}
return "lokal";
}
private String resolveUsername(Authentication authentication) {
Object principal = authentication.getPrincipal();
if (principal instanceof OidcUser oidcUser) {
String preferredUsername = oidcUser.getPreferredUsername();
if (preferredUsername != null && !preferredUsername.isBlank()) {
return preferredUsername;
}
String email = oidcUser.getEmail();
if (email != null && !email.isBlank()) {
return email;
}
String fullName = oidcUser.getFullName();
if (fullName != null && !fullName.isBlank()) {
return fullName;
}
}
return authentication.getName();
}
}

View File

@@ -2,34 +2,73 @@ package de.assecutor.emulatorstation.base.ui.view.security;
import com.vaadin.flow.spring.security.VaadinWebSecurity; import com.vaadin.flow.spring.security.VaadinWebSecurity;
import de.assecutor.emulatorstation.base.ui.view.LoginView; import de.assecutor.emulatorstation.base.ui.view.LoginView;
import de.assecutor.emulatorstation.base.ui.view.SimCardConfigurationView;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.authentication.session.SessionFixationProtectionStrategy; import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import de.assecutor.emulatorstation.Application; import de.assecutor.emulatorstation.Application;
@Configuration @Configuration
public class SecurityConfig extends VaadinWebSecurity { public class SecurityConfig extends VaadinWebSecurity {
private final LoginSessionService loginSessionService;
private final SsoConfiguration ssoConfiguration;
public SecurityConfig(LoginSessionService loginSessionService, SsoConfiguration ssoConfiguration) {
this.loginSessionService = loginSessionService;
this.ssoConfiguration = ssoConfiguration;
}
@Override @Override
protected void configure(HttpSecurity http) throws Exception { protected void configure(HttpSecurity http) throws Exception {
super.configure(http); super.configure(http);
if (ssoConfiguration.isEnabled()) {
setLoginView(http, LoginView.class); setLoginView(http, LoginView.class);
} else {
setLoginView(http, SimCardConfigurationView.class);
}
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()) .sessionFixation(fixation -> fixation.migrateSession()).maximumSessions(Application.MAX_ACTIVE_SESSIONS)
.maximumSessions(Application.MAX_ACTIVE_SESSIONS) .maxSessionsPreventsLogin(false));
.maxSessionsPreventsLogin(false))
.logout( if (ssoConfiguration.isEnabled()) {
logout -> logout.logoutSuccessUrl("/login").addLogoutHandler((request, response, authentication) -> { LogoutSuccessHandler logoutSuccessHandler = oidcLogoutSuccessHandler("{baseUrl}/login");
if (authentication != null) {
String username = authentication.getName(); http.oauth2Login(
Application.activeNiederlassungen.entrySet() oauth2 -> oauth2.loginPage("/login").defaultSuccessUrl("/login", true).failureUrl("/login?error"));
.removeIf(entry -> entry.getValue().equals(username));
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("/"));
} }
} }

View File

@@ -11,11 +11,10 @@ public class SessionListener implements SessionDestroyListener {
@Override @Override
public void sessionDestroy(SessionDestroyEvent event) { public void sessionDestroy(SessionDestroyEvent event) {
String username = (String) event.getSession().getAttribute("user");
NiederlassungInfo niederlassung = (NiederlassungInfo) event.getSession().getAttribute("niederlassung"); NiederlassungInfo niederlassung = (NiederlassungInfo) event.getSession().getAttribute("niederlassung");
String sessionId = (String) event.getSession().getAttribute("sessionId"); String sessionId = (String) event.getSession().getAttribute("sessionId");
if (username != null && niederlassung != null) { if (niederlassung != null) {
Application.activeNiederlassungen.remove(niederlassung.name()); Application.activeNiederlassungen.remove(niederlassung.name());
} }
if (sessionId != null) { if (sessionId != null) {

View File

@@ -0,0 +1,31 @@
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,
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() {
return enabled;
}
}

View File

@@ -1,54 +0,0 @@
package de.assecutor.emulatorstation.base.ui.view.security;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.router.BeforeEnterEvent;
import com.vaadin.flow.server.VaadinService;
import com.vaadin.flow.server.VaadinServiceInitListener;
import com.vaadin.flow.shared.Registration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class VaadinAccessHandler {
@Bean
public VaadinServiceInitListener registerSessionGuard() {
return event -> event.getSource().addUIInitListener(uiInit -> {
UI ui = uiInit.getUI();
Registration reg = ui.addBeforeEnterListener(this::guardRoute);
ui.addDetachListener(detach -> reg.remove());
});
}
private void guardRoute(BeforeEnterEvent event) {
var request = VaadinService.getCurrentRequest();
if (request == null) {
if (!event.getLocation().getPath().equals("main")) {
event.rerouteTo("main");
}
return;
}
var session = request.getWrappedSession(false);
if (session == null) {
if (!event.getLocation().getPath().equals("main")) {
event.rerouteTo("main");
}
return;
}
// Login-Route immer erlauben
String path = event.getLocation().getPath();
if (path.equals("login")) {
return;
}
// Wenn keine Benutzer-Session vorhanden ist, immer zu "main"
Object user = session.getAttribute("user");
if (user == null) {
if (!path.equals("main")) {
event.rerouteTo("main");
}
}
}
}

View File

@@ -0,0 +1,11 @@
package de.assecutor.emulatorstation.pojo;
import java.io.Serializable;
public record CourierInfo(long courierId, String courierSid, long userId, String userAccount,
String environment) implements Serializable {
public String displayLabel() {
return environment.toUpperCase() + " | " + courierSid + " | " + userAccount + " | User " + userId;
}
}

View File

@@ -0,0 +1,10 @@
package de.assecutor.emulatorstation.pojo;
import java.io.Serializable;
public record SimCardInfo(long simCardId, long customerId, Long userId, String secretValue) implements Serializable {
public String displayLabel() {
return secretValue;
}
}

View File

@@ -1,20 +0,0 @@
package util;
import org.springframework.stereotype.Service;
import java.util.Optional;
import java.util.prefs.Preferences;
@Service
public class PreferencesKeyValueStore {
private final Preferences prefs = Preferences.userRoot().node("emulatorstation");
public void put(String key, String value) {
prefs.put(key, value);
}
public Optional<String> get(String key) {
return Optional.ofNullable(prefs.get(key, null));
}
}

View File

@@ -1,5 +1,8 @@
server.port=${PORT:8080} server.port=${PORT:8080}
logging.level.org.atmosphere=warn logging.level.org.atmosphere=warn
spring.config.import=optional:file:./application-secrets.properties,optional:file:./.env[.properties]
app.sso.enabled=${APP_SSO_ENABLED:true}
app.emulator.server-ip=${EMULATOR_SERVER_IP:172.16.0.158}
# Launch the default browser when starting the application in development mode # Launch the default browser when starting the application in development mode
vaadin.launch-browser=true vaadin.launch-browser=true
@@ -21,11 +24,12 @@ server.servlet.session.cookie.max-age=180d
server.servlet.session.cookie.http-only=true server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=false server.servlet.session.cookie.secure=false
server.servlet.session.persistent=true server.servlet.session.persistent=true
server.forward-headers-strategy=framework
# Vaadin session configuration # Vaadin session configuration
vaadin.heartbeatInterval=300 vaadin.heartbeatInterval=300
vaadin.closeIdleSessions=false vaadin.closeIdleSessions=false
# Disable Spring Boot's default generated user/password (we handle auth via Vaadin & custom login) # Disable Spring Boot's default generated user/password (auth is handled via Azure AD SSO)
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration

View File

@@ -0,0 +1,44 @@
package de.assecutor.emulatorstation.base.domain;
import de.assecutor.emulatorstation.pojo.NiederlassungInfo;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class NiederlassungResolverTest {
private final NiederlassungResolver niederlassungResolver = new NiederlassungResolver();
@Test
void resolvesNiederlassungFromKnownSimCodes() {
assertNiederlassung("EMU_B", "Berlin");
assertNiederlassung("EMU_HB", "Bremen");
assertNiederlassung("EMU_HH", "Hamburg");
assertNiederlassung("EMU_E", "Essen");
assertNiederlassung("EMU_L", "Leipzig");
assertNiederlassung("EMU_DD", "Dresden");
assertNiederlassung("EMU_H", "Hannover");
assertNiederlassung("EMU_S", "Stuttgart");
assertNiederlassung("EMU_F", "Frankfurt am Main");
assertNiederlassung("EMU_GFL", "Geschäftführung");
}
@Test
void resolvesSimCodesCaseInsensitive() {
assertEquals("HH", niederlassungResolver.extractCode("emu_hh"));
assertNiederlassung(" emu_b ", "Berlin");
}
@Test
void returnsNullForUnsupportedSimNames() {
assertNull(niederlassungResolver.extractCode("EMULATOR172_16_0_158"));
assertNull(niederlassungResolver.resolveFromSecretValue("EMU_X"));
assertNull(niederlassungResolver.resolveFromSecretValue(null));
}
private void assertNiederlassung(String secretValue, String expectedName) {
NiederlassungInfo niederlassungInfo = niederlassungResolver.resolveFromSecretValue(secretValue);
assertEquals(expectedName, niederlassungInfo.name());
}
}