From 8b6008a00255f2cf1c6af433f44fedf172cca70b Mon Sep 17 00:00:00 2001 From: Jottyfan Date: Sun, 25 Oct 2026 14:27:16 +0100 Subject: [PATCH] fixes nextcloud openid changes --- build.gradle | 3 +- .../bico/modules/CommonController.java | 47 +- src/main/resources/application.properties | 3 + .../bico/config/TestOpenIDConnection.java | 462 ++++++++++++++++++ 4 files changed, 487 insertions(+), 28 deletions(-) create mode 100644 src/test/java/de/jottyfan/bico/config/TestOpenIDConnection.java diff --git a/build.gradle b/build.gradle index 2101028..3fad465 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'de.jottyfan.bico' -version = '0.3.0' +version = '0.3.1' description = """BibleClassOrganizer""" @@ -72,6 +72,7 @@ dependencies { runtimeOnly 'org.springframework.boot:spring-boot-starter-tomcat' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'com.fasterxml.jackson.core:jackson-databind' } test { diff --git a/src/main/java/de/jottyfan/bico/modules/CommonController.java b/src/main/java/de/jottyfan/bico/modules/CommonController.java index 82e47a2..c12f723 100644 --- a/src/main/java/de/jottyfan/bico/modules/CommonController.java +++ b/src/main/java/de/jottyfan/bico/modules/CommonController.java @@ -1,12 +1,14 @@ package de.jottyfan.bico.modules; import java.security.Principal; +import java.util.Collection; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.web.bind.annotation.ModelAttribute; @@ -46,46 +48,37 @@ public abstract class CommonController { } } - @ModelAttribute("hasBUrole") - public Boolean hasBURole(Principal principal) { - OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) principal; - if (token != null) { + private Boolean hasRoles(Principal principal, String... requiredRoles) { + if (principal instanceof OAuth2AuthenticationToken token) { OAuth2User user = token.getPrincipal(); - @SuppressWarnings("unchecked") - List roles = (List) user.getAttributes().get("roles"); - return roles.contains("Bibelunterricht"); + Object rolesAttr = user.getAttributes().get("roles"); + if (rolesAttr == null && user instanceof OidcUser oidcUser && oidcUser.getUserInfo() != null) { + rolesAttr = oidcUser.getUserInfo().getClaims().get("roles"); + } + if (rolesAttr instanceof Collection roles) { + return requiredRoles.length > 0 ? roles.containsAll(List.of(requiredRoles)) : roles.size() > 0; + } + Main.LOGGER.warn("no roles claim found, available attributes: {}", user.getAttributes().keySet()); + return false; } else { Main.LOGGER.warn("token is null, no roles can be detected"); return false; } } + @ModelAttribute("hasBUrole") + public Boolean hasBURole(Principal principal) { + return hasRoles(principal, "Bibelunterricht"); + } + @ModelAttribute("hasDateRole") public Boolean hasDateRole(Principal principal) { - OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) principal; - if (token != null) { - OAuth2User user = token.getPrincipal(); - @SuppressWarnings("unchecked") - List roles = (List) user.getAttributes().get("roles"); - return roles.contains("Kinderstunde klein"); - } else { - Main.LOGGER.warn("token is null, no roles can be detected"); - return false; - } + return hasRoles(principal, "Kinderstunde klein"); } @ModelAttribute("hasAnyRole") public Boolean hasAnyRole(Principal principal) { - OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) principal; - if (token != null) { - OAuth2User user = token.getPrincipal(); - @SuppressWarnings("unchecked") - List roles = (List) user.getAttributes().get("roles"); - return roles.size() > 0; - } else { - Main.LOGGER.warn("token is null, no roles can be detected"); - return false; - } + return hasRoles(principal); } /** diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 7801ebd..33587c5 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -10,9 +10,12 @@ spring.datasource.password = ${db.password} server.servlet.context-path = ${my.context-path:/BiCO} # nextcloud open ID connection +spring.security.oauth2.client.registration.nextcloud.scope = openid,profile,roles spring.security.oauth2.client.provider.nextcloud.issuer-uri = ${nextcloud.issuer-uri} spring.security.oauth2.client.registration.nextcloud.client-id = ${nextcloud.client-id} spring.security.oauth2.client.registration.nextcloud.client-secret = ${nextcloud.client-secret} spring.security.oauth2.client.registration.nextcloud.authorization-grant-type = authorization_code spring.security.oauth2.client.registration.nextcloud.redirect-uri = ${nextcloud.redirect-uri} spring.security.oauth2.client.registration.nextcloud.client-authentication-method = client_secret_post + + diff --git a/src/test/java/de/jottyfan/bico/config/TestOpenIDConnection.java b/src/test/java/de/jottyfan/bico/config/TestOpenIDConnection.java new file mode 100644 index 0000000..640d08f --- /dev/null +++ b/src/test/java/de/jottyfan/bico/config/TestOpenIDConnection.java @@ -0,0 +1,462 @@ +package de.jottyfan.bico.config; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.awt.Desktop; +import java.awt.GraphicsEnvironment; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.net.URI; +import java.net.URL; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class TestOpenIDConnection { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final TypeReference> MAP_TYPE = new TypeReference<>() { + }; + + private static final String KEY_ISSUER = "nextcloud.issuer-uri"; + private static final String KEY_CLIENT_ID = "spring.security.oauth2.client.registration.nextcloud.client-id"; + private static final String KEY_CLIENT_SECRET = "spring.security.oauth2.client.registration.nextcloud.client-secret"; + private static final String KEY_SCOPE = "spring.security.oauth2.client.registration.nextcloud.scope"; + private static final String KEY_TEST_SCOPE = "nextcloud.test.scope"; + private static final String KEY_IMPORT = "spring.config.import"; + + private static final String DEFAULT_SCOPE = "openid profile roles"; + private static final String DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; + + private static final Pattern PLACEHOLDER = Pattern.compile("\\$\\{([^}:]+)(?::([^}]*))?}"); + + // ------------------------------------------------------------------ + // 1) Live-Test gegen deine Nextcloud + // ------------------------------------------------------------------ + @Test + void liveClaimsVonNextcloud() throws Exception { + Properties props = loadApplicationProperties(); + + String issuer = props.getProperty(KEY_ISSUER); + String clientId = props.getProperty(KEY_CLIENT_ID); + String clientSecret = props.getProperty(KEY_CLIENT_SECRET); // darf bei öffentlichen Clients fehlen + + List missing = new ArrayList<>(); + if (issuer == null || issuer.isBlank()) { + missing.add(KEY_ISSUER); + } + if (clientId == null || clientId.isBlank()) { + missing.add(KEY_CLIENT_ID); + } + if (!missing.isEmpty()) { + fail("Fehlende Einträge in der application.properties (auch /etc-Import prüfen): " + missing); + } + + String scope = props.getProperty(KEY_TEST_SCOPE, props.getProperty(KEY_SCOPE, DEFAULT_SCOPE)).replace(',', ' ') + .trim(); + System.out.println("Angeforderter Scope: " + scope); + + // Endpunkte aus dem Discovery-Dokument + String base = issuer.endsWith("/") ? issuer.substring(0, issuer.length() - 1) : issuer; + Map config = getJson(base + "/.well-known/openid-configuration", null); + String deviceEndpoint = requireEndpoint(config, "device_authorization_endpoint"); + String tokenEndpoint = requireEndpoint(config, "token_endpoint"); + String userinfoEndpoint = requireEndpoint(config, "userinfo_endpoint"); + + // Anmeldung per Device Flow + Map tokens = loginWithDeviceFlow(deviceEndpoint, tokenEndpoint, clientId, clientSecret, scope); + System.out.println("Vom Server gewährter Scope: " + tokens.get("scope")); + + String accessToken = stringOrFail(tokens, "access_token"); + String idTokenValue = stringOrFail(tokens, "id_token"); + + OidcUser user = new DefaultOidcUser(AuthorityUtils.createAuthorityList("ROLE_USER"), idTokenFromJwt(idTokenValue), + new OidcUserInfo(getJson(userinfoEndpoint, accessToken))); + + Map idTokenClaims = user.getIdToken().getClaims(); + Map userInfoClaims = user.getUserInfo().getClaims(); + + print("getIdToken().getClaims()", idTokenClaims); + print("getUserInfo().getClaims()", userInfoClaims); + + System.out.println("roles im ID-Token : " + idTokenClaims.get("roles")); + System.out.println("roles im Userinfo : " + userInfoClaims.get("roles")); + System.out.println("groups im ID-Token: " + idTokenClaims.get("groups")); + System.out.println("groups im Userinfo: " + userInfoClaims.get("groups")); + + assertTrue(idTokenClaims.containsKey("roles") || userInfoClaims.containsKey("roles"), + "Weder ID-Token noch Userinfo enthalten 'roles' - Scope 'roles' angefordert?"); + } + + // ------------------------------------------------------------------ + // 2) Offline-Test: Verhalten von getClaims() und null-sichere Extraktion + // ------------------------------------------------------------------ + @Test + void offlineClaimsUndRollenExtraktion() { + OidcIdToken idToken = new OidcIdToken("dummy-token", Instant.now(), Instant.now().plusSeconds(300), + Map.of("sub", "user1", "iss", "https://cloud.example.org")); + OidcUserInfo userInfo = new OidcUserInfo(Map.of("sub", "user1", "roles", List.of("admin", "student"))); + + OidcUser user = new DefaultOidcUser(AuthorityUtils.createAuthorityList("ROLE_USER"), idToken, userInfo); + + print("getIdToken().getClaims()", user.getIdToken().getClaims()); + print("getUserInfo().getClaims()", user.getUserInfo().getClaims()); + + assertNotNull(user.getIdToken().getClaims()); + assertNotNull(user.getUserInfo().getClaims()); + + // getAttributes() enthält die zusammengeführten Claims aus ID-Token und + // Userinfo + System.out.println("roles via getAttributes(): " + extractRoles(user.getAttributes().get("roles"))); + } + + // ------------------------------------------------------------------ + // Device Authorization Grant (RFC 8628) + // ------------------------------------------------------------------ + private static Map loginWithDeviceFlow(String deviceEndpoint, String tokenEndpoint, String clientId, + String clientSecret, String scope) throws Exception { + + Map form = new LinkedHashMap<>(); + form.put("client_id", clientId); + if (clientSecret != null && !clientSecret.isBlank()) { + form.put("client_secret", clientSecret); + } + form.put("scope", scope); + + HttpResponse response = postForm(deviceEndpoint, form); + if (response.statusCode() != 200) { + String hint = response.body() != null && response.body().contains("invalid_scope") + ? "\n" + probeScopes(deviceEndpoint, clientId, clientSecret, scope) + : " - erlaubt der Client in Nextcloud den Device-Flow?"; + return fail( + "Device-Authorization-Request fehlgeschlagen (" + response.statusCode() + "): " + response.body() + hint); + } + Map device = parseJson(response.body()); + + String deviceCode = stringOrFail(device, "device_code"); + String verificationUri = stringOrFail(device, "verification_uri"); + Object userCode = device.get("user_code"); + Object verificationUriComplete = device.get("verification_uri_complete"); + int interval = device.get("interval") instanceof Number i ? i.intValue() : 5; + int expiresIn = device.get("expires_in") instanceof Number e ? e.intValue() : 300; + + System.out.println(); + System.out.println("==== Anmeldung bei Nextcloud erforderlich ===="); + System.out.println("1. Öffne im Browser: " + verificationUri); + if (userCode != null) { + System.out.println("2. Gib diesen Code ein: " + userCode); + } + if (verificationUriComplete != null) { + System.out.println(" Oder öffne direkt: " + verificationUriComplete); + } + System.out.println("Der Test wartet bis zu " + expiresIn + " Sekunden auf die Anmeldung ..."); + System.out.println("==============================================="); + System.out.println(); + tryOpenBrowser(verificationUriComplete != null ? verificationUriComplete.toString() : verificationUri); + + Instant deadline = Instant.now().plusSeconds(expiresIn); + while (Instant.now().isBefore(deadline)) { + Thread.sleep(interval * 1000L); + + Map tokenForm = new LinkedHashMap<>(); + tokenForm.put("grant_type", DEVICE_GRANT); + tokenForm.put("device_code", deviceCode); + tokenForm.put("client_id", clientId); + if (clientSecret != null && !clientSecret.isBlank()) { + tokenForm.put("client_secret", clientSecret); + } + + HttpResponse tokenResponse = postForm(tokenEndpoint, tokenForm); + Map body = parseJson(tokenResponse.body()); + if (tokenResponse.statusCode() == 200) { + return body; + } + + String error = String.valueOf(body.get("error")); + switch (error) { + case "authorization_pending" -> { + /* Benutzer hat die Anmeldung noch nicht abgeschlossen */ } + case "slow_down" -> interval += 5; + default -> fail("Token-Abruf fehlgeschlagen (" + tokenResponse.statusCode() + "): " + tokenResponse.body()); + } + } + return fail("Zeit für die Anmeldung abgelaufen."); + } + + /** + * Fragt jeden Scope einzeln an, um herauszufinden, welche der Client verwenden + * darf. Die dabei erzeugten Device-Codes werden nicht weiterverwendet und + * laufen von selbst ab. + */ + private static String probeScopes(String deviceEndpoint, String clientId, String clientSecret, String scope) + throws Exception { + List allowed = new ArrayList<>(); + List rejected = new ArrayList<>(); + for (String single : scope.split("\\s+")) { + if (single.isBlank()) { + continue; + } + Map form = new LinkedHashMap<>(); + form.put("client_id", clientId); + if (clientSecret != null && !clientSecret.isBlank()) { + form.put("client_secret", clientSecret); + } + form.put("scope", single); + (postForm(deviceEndpoint, form).statusCode() == 200 ? allowed : rejected).add(single); + } + return "Einzelprüfung der Scopes: erlaubt=" + allowed + ", abgelehnt=" + rejected; + } + + private static void tryOpenBrowser(String url) { + try { + if (!GraphicsEnvironment.isHeadless() && Desktop.isDesktopSupported() + && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(URI.create(url)); + } + } catch (Exception e) { + // nicht schlimm, die Adresse steht in der Konsole + } + } + + private static String requireEndpoint(Map config, String key) { + Object value = config.get(key); + if (value == null) { + return fail("Im Discovery-Dokument fehlt '" + key + "' - unterstützt die OIDC-App den Device-Flow?"); + } + return value.toString(); + } + + private static String stringOrFail(Map map, String key) { + Object value = map.get(key); + if (value == null) { + return fail("Antwort enthält kein '" + key + "'. Vorhandene Schlüssel: " + map.keySet()); + } + return value.toString(); + } + + // ------------------------------------------------------------------ + // Properties laden (inkl. spring.config.import) + // ------------------------------------------------------------------ + + /** + * Lädt alle application.properties vom Klassenpfad (Einträge aus + * src/test/resources überschreiben die aus src/main/resources), danach die in + * spring.config.import genannten Dateien, die ihrerseits die Einträge der + * importierenden Dateien überschreiben. + */ + private static Properties loadApplicationProperties() throws IOException { + List urls = Collections + .list(TestOpenIDConnection.class.getClassLoader().getResources("application.properties")); + Collections.reverse(urls); // zuerst main, dann test + + Properties props = new Properties(); + for (URL url : urls) { + try (InputStream in = url.openStream()) { + loadInto(props, in); + } + } + + processImports(props, props.getProperty(KEY_IMPORT), new HashSet<>()); + resolvePlaceholders(props); + return props; + } + + private static void loadInto(Properties target, InputStream in) throws IOException { + try (Reader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) { + target.load(reader); + } + } + + /** + * Verfolgt spring.config.import (kommagetrennt, auch rekursiv in den + * importierten Dateien). + */ + private static void processImports(Properties target, String importValue, Set visited) throws IOException { + if (importValue == null || importValue.isBlank()) { + return; + } + for (String raw : importValue.split(",")) { + String location = raw.trim(); + if (location.isEmpty()) { + continue; + } + boolean optional = false; + if (location.startsWith("optional:")) { + optional = true; + location = location.substring("optional:".length()); + } + if (!visited.add(location)) { + continue; // Zyklus oder doppelter Import + } + + Properties imported = new Properties(); + if (location.startsWith("classpath:")) { + String resource = location.substring("classpath:".length()); + if (resource.startsWith("/")) { + resource = resource.substring(1); + } + try (InputStream in = TestOpenIDConnection.class.getClassLoader().getResourceAsStream(resource)) { + if (in == null) { + reportMissing(location, optional); + continue; + } + loadInto(imported, in); + } + } else { + if (location.startsWith("file:")) { + location = location.substring("file:".length()); + } + Path path = Path.of(location); + if (Files.isDirectory(path)) { + path = path.resolve("application.properties"); + } + String name = path.getFileName().toString(); + if (name.endsWith(".yml") || name.endsWith(".yaml")) { + System.out.println("Hinweis: YAML-Import wird vom Test nicht gelesen: " + path); + continue; + } + if (!Files.isReadable(path)) { + reportMissing(path.toString(), optional); + continue; + } + try (InputStream in = Files.newInputStream(path)) { + loadInto(imported, in); + } + System.out.println("Importiert: " + path); + } + + target.putAll(imported); + processImports(target, imported.getProperty(KEY_IMPORT), visited); + } + } + + private static void reportMissing(String location, boolean optional) { + System.out.println( + (optional ? "Hinweis (optional): " : "Warnung: ") + "Import nicht gefunden oder nicht lesbar: " + location); + } + + /** + * Ersetzt ${key} und ${key:default} durch Werte aus den geladenen Properties. + */ + private static void resolvePlaceholders(Properties props) { + for (String key : props.stringPropertyNames()) { + props.setProperty(key, resolve(props.getProperty(key), props, 0)); + } + } + + private static String resolve(String value, Properties props, int depth) { + if (depth > 10) { + return value; + } + Matcher m = PLACEHOLDER.matcher(value); + StringBuilder sb = new StringBuilder(); + while (m.find()) { + String replacement = props.getProperty(m.group(1).trim()); + if (replacement != null) { + replacement = resolve(replacement, props, depth + 1); + } else { + replacement = m.group(2) != null ? m.group(2) : m.group(0); + } + m.appendReplacement(sb, Matcher.quoteReplacement(replacement)); + } + m.appendTail(sb); + return sb.toString(); + } + + // ------------------------------------------------------------------ + // HTTP- und JSON-Hilfen + // ------------------------------------------------------------------ + private static List extractRoles(Object rolesAttr) { + return rolesAttr instanceof List list ? list.stream().map(String::valueOf).toList() : List.of(); + } + + private static Map getJson(String url, String bearerToken) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url)).header("Accept", "application/json").GET(); + if (bearerToken != null) { + builder.header("Authorization", "Bearer " + bearerToken); + } + HttpResponse response = HttpClient.newHttpClient().send(builder.build(), + HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IllegalStateException(url + " antwortete mit " + response.statusCode() + ": " + response.body()); + } + return parseJson(response.body()); + } + + private static HttpResponse postForm(String url, Map form) throws Exception { + String body = form.entrySet().stream().map(e -> urlEncode(e.getKey()) + "=" + urlEncode(e.getValue())) + .collect(Collectors.joining("&")); + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .header("Content-Type", "application/x-www-form-urlencoded").header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)).build(); + return HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + } + + private static String urlEncode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private static Map parseJson(String json) { + try { + return MAPPER.readValue(json, MAP_TYPE); + } catch (Exception e) { + throw new IllegalStateException("Antwort ist kein JSON: " + json, e); + } + } + + /** + * Dekodiert nur den Payload (keine Signaturprüfung) - ausreichend zum Debuggen. + */ + private static OidcIdToken idTokenFromJwt(String jwt) throws Exception { + String[] parts = jwt.split("\\."); + if (parts.length < 2) { + throw new IllegalArgumentException("Kein gültiges JWT"); + } + Map claims = MAPPER.readValue(Base64.getUrlDecoder().decode(parts[1]), MAP_TYPE); + return new OidcIdToken(jwt, toInstant(claims.get("iat")), toInstant(claims.get("exp")), claims); + } + + private static Instant toInstant(Object epochSeconds) { + return epochSeconds instanceof Number n ? Instant.ofEpochSecond(n.longValue()) : null; + } + + private static void print(String title, Map claims) { + System.out.println("=== " + title + " ==="); + try { + System.out.println(MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(claims)); + } catch (Exception e) { + System.out.println(claims); + } + } +} \ No newline at end of file