Security-Lab

31 Java-Dateien. 7 zentrale Dateien werden direkt mit echtem Quellcode und ihrem Zusammenspiel erklärt.

Zurück zu Code-Labs

Kapitel 07 · Security, Rollen und Audit

Was dieses Lab zeigt

Modelliert Authentisierung, Autorisierung und Audit als explizite Architekturbausteine. Rollen, Berechtigungen und nachvollziehbare Sicherheitsentscheidungen bleiben testbar.

Lernziele

  • Rollen und Rechte trennen
  • Autorisierung zentral prüfen
  • Audit-Trails nachvollziehbar machen

Technik und Schwerpunkte

JDK-Lab31 Java-Dateien1 Tests/RunnerAuthorizationRBACAudit
Echter Quellcode aus diesem Lab

Geführter Codepfad

Dieses Lab zeigt den Sicherheitsweg vom signierten Token über Authentisierung und Policy-Entscheidung bis zum sicheren Lesen und Auditieren. Fehler werden bewusst in stabile Problem-Details übersetzt.

SecurityDeepDiveLabSignedTokenServiceAuthorizationServiceOrderQueryServiceSecurityExceptionMapperInMemoryAuditSinkRun5BTestRunner
Lesereihenfolge der zentralen Klassen. Die Pfeile zeigen den didaktischen Weg durch den realen Quellcode, nicht zwingend jeden Laufzeitaufruf.
1. SecurityDeepDiveLabStartet authentisierte und nicht autorisierte Szenarien mit realen Policies des Labs.
2. SignedTokenServiceSigniert und validiert Token-Claims und trennt technische Tokenprüfung von fachlicher Autorisierung.
3. AuthorizationServiceBündelt Berechtigungsprüfung und liefert eine explizite AccessDecision.
4. OrderQueryServiceWendet Read-Policy, Denial-Visibility und Repository-Zugriff im konkreten Order-Use-Case an.
5. SecurityExceptionMapperÜbersetzt Security-Ausnahmen in konsistente ProblemDetails ohne interne Informationen preiszugeben.
6. InMemoryAuditSinkSpeichert sicherheitsrelevante Entscheidungen als AuditEvents.
7. Run5BTestRunnerPrüft Tokenfehler, Rollen, Objektzugriff, Verschleierung und Auditierung.

1. SecurityDeepDiveLab

src/main/java/com/example/securitydeepdive/SecurityDeepDiveLab.java
Java-Datei öffnen
Rolle im Ablauf

Startet authentisierte und nicht autorisierte Szenarien mit realen Policies des Labs.

Im Lesepfad folgt SignedTokenService: Signiert und validiert Token-Claims und trennt technische Tokenprüfung von fachlicher Autorisierung.

Typ
class SecurityDeepDiveLab
Verwendet
SignedTokenService, AuthorizationService, OrderQueryService, InMemoryAuditSink
Verwendet von
Einstiege
main(String[] args)
package com.example.securitydeepdive;

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Set;

public final class SecurityDeepDiveLab {
    public static void main(String[] args) {
        Clock clock = Clock.fixed(Instant.parse("2026-07-09T12:00:00Z"), ZoneOffset.UTC);
        SignedTokenService tokens = new SignedTokenService("run5b-secret", "https://issuer.example", "enterprise-api");
        SecurityContextFactory factory = new SecurityContextFactory(tokens, clock);
        InMemoryOrderRepository repository = new InMemoryOrderRepository();
        InMemoryAuditSink audit = new InMemoryAuditSink();
        AuthorizationService authz = new AuthorizationService(audit, clock);
        OrderQueryService query = new OrderQueryService(repository, authz);

        repository.save(new OrderView(new OrderId("ORD-1000"), new CustomerId("customer-1"), "CONFIRMED", "pay_****_7788"));
        String customerToken = tokens.issue(new TokenClaims("customer-1", "tenant-main", "https://issuer.example", "enterprise-api", Set.of(Role.CUSTOMER), Set.of("orders:read"), clock.instant().plusSeconds(3600)));
        SecurityContext customer = factory.fromBearerToken("Bearer " + customerToken, "corr-demo", "127.0.0.1");
        OrderView visible = query.getOrder(customer, new OrderId("ORD-1000"));
        System.out.println("visible=" + visible.orderId().value() + ", actor=" + customer.principal().orElseThrow().subject());
        System.out.println("auditEvents=" + audit.events().size());
        System.out.println("RUN5B_DEMO_OK");
    }
}

2. SignedTokenService

src/main/java/com/example/securitydeepdive/SignedTokenService.java
Java-Datei öffnen
Rolle im Ablauf

Signiert und validiert Token-Claims und trennt technische Tokenprüfung von fachlicher Autorisierung.

Im Lesepfad folgt AuthorizationService: Bündelt Berechtigungsprüfung und liefert eine explizite AccessDecision.

Typ
class SignedTokenService
Verwendet
Verwendet von
SecurityDeepDiveLab, Run5BTestRunner
Einstiege
issue(TokenClaims claims), validate(String token, Instant now)
package com.example.securitydeepdive;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.Set;
import java.util.stream.Collectors;

// Didaktischer JWT-aehnlicher Token-Service. In Produktion: etablierte JOSE/OIDC-Bibliothek verwenden.
public final class SignedTokenService {
    private final byte[] secret;
    private final String issuer;
    private final String audience;

    public SignedTokenService(String secret, String issuer, String audience) {
        this.secret = secret.getBytes(StandardCharsets.UTF_8);
        this.issuer = issuer;
        this.audience = audience;
    }

    public String issue(TokenClaims claims) {
        String header = "alg=HS256;typ=LAB";
        String payload = String.join(";",
                "sub=" + claims.subject(),
                "tenant=" + claims.tenantId(),
                "iss=" + issuer,
                "aud=" + audience,
                "roles=" + claims.roles().stream().map(Enum::name).sorted().collect(Collectors.joining(",")),
                "scopes=" + claims.scopes().stream().sorted().collect(Collectors.joining(",")),
                "exp=" + claims.expiresAt().getEpochSecond());
        String encodedHeader = b64(header.getBytes(StandardCharsets.UTF_8));
        String encodedPayload = b64(payload.getBytes(StandardCharsets.UTF_8));
        String signature = sign(encodedHeader + "." + encodedPayload);
        return encodedHeader + "." + encodedPayload + "." + signature;
    }

    public TokenClaims validate(String token, Instant now) {
        String[] parts = token.split("\\.");
        if (parts.length != 3) throw new TokenValidationException("token format invalid");
        String expected = sign(parts[0] + "." + parts[1]);
        if (!constantTimeEquals(expected, parts[2])) throw new TokenValidationException("signature invalid");
        String payload = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8);
        java.util.Map<String, String> map = parse(payload);
        if (!issuer.equals(map.get("iss"))) throw new TokenValidationException("issuer invalid");
        if (!audience.equals(map.get("aud"))) throw new TokenValidationException("audience invalid");
        Instant exp = Instant.ofEpochSecond(Long.parseLong(map.get("exp")));
        if (!exp.isAfter(now)) throw new TokenValidationException("token expired");
        Set<Role> roles = split(map.get("roles")).stream().map(Role::valueOf).collect(Collectors.toUnmodifiableSet());
        Set<String> scopes = split(map.get("scopes")).stream().collect(Collectors.toUnmodifiableSet());
        return new TokenClaims(map.get("sub"), map.get("tenant"), map.get("iss"), map.get("aud"), roles, scopes, exp);
    }

    private String sign(String value) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret, "HmacSHA256"));
            return b64(mac.doFinal(value.getBytes(StandardCharsets.UTF_8)));
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
    }

    private static String b64(byte[] bytes) {
        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
    }

    private static boolean constantTimeEquals(String a, String b) {
        return java.security.MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
    }

    private static java.util.Map<String, String> parse(String payload) {
        java.util.Map<String, String> map = new java.util.HashMap<>();
        for (String part : payload.split(";")) {
            String[] kv = part.split("=", 2);
            if (kv.length == 2) map.put(kv[0], kv[1]);
        }
        return map;
    }

    private static java.util.List<String> split(String value) {
        if (value == null || value.isBlank()) return java.util.List.of();
        return java.util.Arrays.asList(value.split(","));
    }
}

3. AuthorizationService

src/main/java/com/example/securitydeepdive/AuthorizationService.java
Java-Datei öffnen
Rolle im Ablauf

Bündelt Berechtigungsprüfung und liefert eine explizite AccessDecision.

Im Lesepfad folgt OrderQueryService: Wendet Read-Policy, Denial-Visibility und Repository-Zugriff im konkreten Order-Use-Case an.

Typ
class AuthorizationService
Verwendet
Verwendet von
SecurityDeepDiveLab, OrderQueryService, Run5BTestRunner
Einstiege
require(SecurityContext context, String action, String resourceId, T resource, AccessPolicy<T> policy)
Application Service
package com.example.securitydeepdive;

import java.time.Clock;

// Pattern: Application Service - orchestriert Policy-Entscheidung, Audit und Fehlerabbildung.
public final class AuthorizationService {
    private final AuditSink auditSink;
    private final Clock clock;

    public AuthorizationService(AuditSink auditSink, Clock clock) {
        this.auditSink = auditSink;
        this.clock = clock;
    }

    public <T> void require(SecurityContext context, String action, String resourceId, T resource, AccessPolicy<T> policy) {
        AccessDecision decision = policy.evaluate(context, resource);
        String actor = context.principal().map(SecurityPrincipal::subject).orElse("anonymous");
        switch (decision) {
            case AccessDecision.Allowed allowed -> auditSink.append(new AuditEvent(
                    clock.instant(), context.correlationId(), actor, action, resourceId, "ALLOW", allowed.reason(), context.remoteAddress()));
            case AccessDecision.Denied denied -> {
                auditSink.append(new AuditEvent(
                        clock.instant(), context.correlationId(), actor, action, resourceId, "DENY", denied.reason(), context.remoteAddress()));
                throw switch (denied.visibility()) {
                    case SHOW_FORBIDDEN -> new ForbiddenException("access denied");
                    case HIDE_AS_NOT_FOUND -> new NotFoundException("resource not found");
                };
            }
        }
    }
}

4. OrderQueryService

src/main/java/com/example/securitydeepdive/OrderQueryService.java
Java-Datei öffnen
Rolle im Ablauf

Wendet Read-Policy, Denial-Visibility und Repository-Zugriff im konkreten Order-Use-Case an.

Im Lesepfad folgt SecurityExceptionMapper: Übersetzt Security-Ausnahmen in konsistente ProblemDetails ohne interne Informationen preiszugeben.

Typ
class OrderQueryService
Verwendet
AuthorizationService
Verwendet von
SecurityDeepDiveLab, Run5BTestRunner
Einstiege
getOrder(SecurityContext context, OrderId orderId)
Application Service
package com.example.securitydeepdive;

// Pattern: Application Service - trennt API-Schicht von Sicherheits- und Repository-Details.
public final class OrderQueryService {
    private final OrderRepository orders;
    private final AuthorizationService authorization;
    private final OrderReadPolicy orderReadPolicy = new OrderReadPolicy();

    public OrderQueryService(OrderRepository orders, AuthorizationService authorization) {
        this.orders = orders;
        this.authorization = authorization;
    }

    public OrderView getOrder(SecurityContext context, OrderId orderId) {
        OrderView order = orders.findById(orderId).orElseThrow(() -> new NotFoundException("resource not found"));
        authorization.require(context, "order.read", orderId.value(), order, orderReadPolicy);
        return order;
    }
}

5. SecurityExceptionMapper

src/main/java/com/example/securitydeepdive/SecurityExceptionMapper.java
Java-Datei öffnen
Rolle im Ablauf

Übersetzt Security-Ausnahmen in konsistente ProblemDetails ohne interne Informationen preiszugeben.

Im Lesepfad folgt InMemoryAuditSink: Speichert sicherheitsrelevante Entscheidungen als AuditEvents.

Typ
class SecurityExceptionMapper
Verwendet
Verwendet von
Run5BTestRunner
Einstiege
toProblem(RuntimeException exception, String correlationId)
Mapper
package com.example.securitydeepdive;

import java.util.Map;

// Pattern: Mapper - uebersetzt interne Security-Fehler in sichere API-Problem-Details.
public final class SecurityExceptionMapper {
    public ProblemDetails toProblem(RuntimeException exception, String correlationId) {
        if (exception instanceof UnauthenticatedException) {
            return new ProblemDetails(401, "https://errors.example.com/authentication-required", "Authentication required", "A valid token is required.", correlationId, Map.of());
        }
        if (exception instanceof ForbiddenException) {
            return new ProblemDetails(403, "https://errors.example.com/forbidden", "Forbidden", "You are not allowed to perform this action.", correlationId, Map.of());
        }
        if (exception instanceof NotFoundException) {
            return new ProblemDetails(404, "https://errors.example.com/not-found", "Not found", "The requested resource was not found.", correlationId, Map.of());
        }
        return new ProblemDetails(500, "https://errors.example.com/internal", "Internal error", "The request failed.", correlationId, Map.of());
    }
}

6. InMemoryAuditSink

src/main/java/com/example/securitydeepdive/InMemoryAuditSink.java
Java-Datei öffnen
Rolle im Ablauf

Speichert sicherheitsrelevante Entscheidungen als AuditEvents.

Im Lesepfad folgt Run5BTestRunner: Prüft Tokenfehler, Rollen, Objektzugriff, Verschleierung und Auditierung.

Typ
class InMemoryAuditSinkimplements AuditSink
Verwendet
Verwendet von
SecurityDeepDiveLab, Run5BTestRunner
Einstiege
append(AuditEvent event), events()
Adapter
package com.example.securitydeepdive;

import java.util.ArrayList;
import java.util.List;

// Pattern: Adapter - speichert Audit-Events fuer Tests und Demo im Speicher.
public final class InMemoryAuditSink implements AuditSink {
    private final List<AuditEvent> events = new ArrayList<>();

    @Override
    public synchronized void append(AuditEvent event) {
        events.add(event);
    }

    public synchronized List<AuditEvent> events() {
        return List.copyOf(events);
    }
}

7. Run5BTestRunner

src/test/java/com/example/securitydeepdive/Run5BTestRunner.java
Java-Datei öffnen
Rolle im Ablauf

Prüft Tokenfehler, Rollen, Objektzugriff, Verschleierung und Auditierung.

Damit ist der zentrale Pfad abgeschlossen; der Test-/Runner-Code und die vollständige Dateiliste darunter zeigen die übrigen Varianten.

Typ
class Run5BTestRunner
Verwendet
SignedTokenService, AuthorizationService, OrderQueryService, SecurityExceptionMapper, InMemoryAuditSink
Verwendet von
Einstiege
main(String[] args)
package com.example.securitydeepdive;

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Set;

public final class Run5BTestRunner {
    public static void main(String[] args) {
        testOwnerMayReadOwnOrder();
        testForeignCustomerGetsNotFound();
        testSupportMayReadAnyOrder();
        testInvalidTokenBecomes401();
        testExpiredTokenBecomes401();
        testAuditRecordsAllowAndDeny();
        testServiceScopePolicy();
        System.out.println("RUN5B_TESTS_OK");
    }

    private static Fixture fixture() {
        Clock clock = Clock.fixed(Instant.parse("2026-07-09T12:00:00Z"), ZoneOffset.UTC);
        SignedTokenService tokens = new SignedTokenService("run5b-secret", "https://issuer.example", "enterprise-api");
        InMemoryOrderRepository repo = new InMemoryOrderRepository();
        repo.save(new OrderView(new OrderId("ORD-1"), new CustomerId("customer-1"), "CONFIRMED", "pay_****"));
        InMemoryAuditSink audit = new InMemoryAuditSink();
        AuthorizationService authz = new AuthorizationService(audit, clock);
        return new Fixture(clock, tokens, new SecurityContextFactory(tokens, clock), repo, audit, new OrderQueryService(repo, authz), authz);
    }

    private static void testOwnerMayReadOwnOrder() {
        Fixture f = fixture();
        SecurityContext ctx = f.context("customer-1", Set.of(Role.CUSTOMER), Set.of("orders:read"), 3600);
        OrderView order = f.query.getOrder(ctx, new OrderId("ORD-1"));
        assertEquals("ORD-1", order.orderId().value(), "owner reads own order");
    }

    private static void testForeignCustomerGetsNotFound() {
        Fixture f = fixture();
        SecurityContext ctx = f.context("customer-2", Set.of(Role.CUSTOMER), Set.of("orders:read"), 3600);
        assertThrows(NotFoundException.class, () -> f.query.getOrder(ctx, new OrderId("ORD-1")), "foreign customer hidden as not found");
    }

    private static void testSupportMayReadAnyOrder() {
        Fixture f = fixture();
        SecurityContext ctx = f.context("support-99", Set.of(Role.SUPPORT), Set.of("orders:read"), 3600);
        OrderView order = f.query.getOrder(ctx, new OrderId("ORD-1"));
        assertEquals("CONFIRMED", order.status(), "support reads any order");
    }

    private static void testInvalidTokenBecomes401() {
        Fixture f = fixture();
        SecurityExceptionMapper mapper = new SecurityExceptionMapper();
        try {
            f.factory.fromBearerToken("Bearer invalid.token.value", "corr-401", "127.0.0.1");
            fail("invalid token must fail");
        } catch (UnauthenticatedException ex) {
            ProblemDetails problem = mapper.toProblem(ex, "corr-401");
            assertEquals(401, problem.status(), "invalid token maps to 401");
            assertTrue(!problem.detail().contains("signature"), "internal token details not leaked");
        }
    }

    private static void testExpiredTokenBecomes401() {
        Fixture f = fixture();
        String token = f.tokens.issue(new TokenClaims("customer-1", "tenant-main", "https://issuer.example", "enterprise-api", Set.of(Role.CUSTOMER), Set.of("orders:read"), f.clock.instant().minusSeconds(1)));
        assertThrows(UnauthenticatedException.class, () -> f.factory.fromBearerToken("Bearer " + token, "corr-exp", "127.0.0.1"), "expired token rejected");
    }

    private static void testAuditRecordsAllowAndDeny() {
        Fixture f = fixture();
        SecurityContext owner = f.context("customer-1", Set.of(Role.CUSTOMER), Set.of("orders:read"), 3600);
        f.query.getOrder(owner, new OrderId("ORD-1"));
        SecurityContext stranger = f.context("customer-2", Set.of(Role.CUSTOMER), Set.of("orders:read"), 3600);
        assertThrows(NotFoundException.class, () -> f.query.getOrder(stranger, new OrderId("ORD-1")), "deny audited");
        assertEquals(2, f.audit.events().size(), "allow and deny audit events");
        assertEquals("ALLOW", f.audit.events().get(0).decision(), "first allow");
        assertEquals("DENY", f.audit.events().get(1).decision(), "second deny");
    }

    private static void testServiceScopePolicy() {
        Fixture f = fixture();
        SecurityContext service = f.context("payment-service", Set.of(Role.PAYMENT_SERVICE), Set.of("payments:authorize"), 3600);
        ServiceTokenPolicy policy = new ServiceTokenPolicy("payments:authorize");
        f.authz.require(service, "payment.authorize", "PAY-1", "payment.authorize", policy);
        assertEquals("ALLOW", f.audit.events().get(0).decision(), "service scope allowed");
    }

    private static final class Fixture {
        final Clock clock;
        final SignedTokenService tokens;
        final SecurityContextFactory factory;
        final InMemoryOrderRepository repo;
        final InMemoryAuditSink audit;
        final OrderQueryService query;
        final AuthorizationService authz;

        Fixture(Clock clock, SignedTokenService tokens, SecurityContextFactory factory, InMemoryOrderRepository repo, InMemoryAuditSink audit, OrderQueryService query, AuthorizationService authz) {
            this.clock = clock; this.tokens = tokens; this.factory = factory; this.repo = repo; this.audit = audit; this.query = query; this.authz = authz;
        }

        SecurityContext context(String subject, Set<Role> roles, Set<String> scopes, long validSeconds) {
            String token = tokens.issue(new TokenClaims(subject, "tenant-main", "https://issuer.example", "enterprise-api", roles, scopes, clock.instant().plusSeconds(validSeconds)));
            return factory.fromBearerToken("Bearer " + token, "corr-test", "127.0.0.1");
        }
    }

    private static void assertEquals(Object expected, Object actual, String message) {
        if (!java.util.Objects.equals(expected, actual)) throw new AssertionError(message + " expected=" + expected + " actual=" + actual);
    }
    private static void assertTrue(boolean condition, String message) { if (!condition) throw new AssertionError(message); }
    private static void fail(String message) { throw new AssertionError(message); }
    private static void assertThrows(Class<? extends Throwable> expected, Runnable action, String message) {
        try { action.run(); } catch (Throwable t) { if (expected.isInstance(t)) return; throw new AssertionError(message + " wrong exception=" + t); }
        throw new AssertionError(message + " no exception");
    }
}
Alle Projektdateien öffnen (34 Einträge)
⌂ Cockpit