fixes nextcloud openid changes

This commit is contained in:
Jottyfan committed 2026-10-25 14:27:16 +01:00
1 parent e6594f3ad2
commit 8b6008a002
4 files changed
+487 -28

No files matched your search

+2 -1
View File
@@ -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 {
@@ -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<String> roles = (List<String>) 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<String> roles = (List<String>) 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<String> roles = (List<String>) 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);
}
/**
@@ -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
@@ -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<String, Object>> 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<String> 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<String, Object> 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<String, Object> 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<String, Object> idTokenClaims = user.getIdToken().getClaims();
Map<String, Object> 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<String, Object> loginWithDeviceFlow(String deviceEndpoint, String tokenEndpoint, String clientId,
String clientSecret, String scope) throws Exception {
Map<String, String> form = new LinkedHashMap<>();
form.put("client_id", clientId);
if (clientSecret != null && !clientSecret.isBlank()) {
form.put("client_secret", clientSecret);
}
form.put("scope", scope);
HttpResponse<String> 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<String, Object> 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<String, String> 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<String> tokenResponse = postForm(tokenEndpoint, tokenForm);
Map<String, Object> 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<String> allowed = new ArrayList<>();
List<String> rejected = new ArrayList<>();
for (String single : scope.split("\\s+")) {
if (single.isBlank()) {
continue;
}
Map<String, String> 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<String, Object> 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<String, Object> 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<URL> 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<String> 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<String> extractRoles(Object rolesAttr) {
return rolesAttr instanceof List<?> list ? list.stream().map(String::valueOf).toList() : List.of();
}
private static Map<String, Object> 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<String> 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<String> postForm(String url, Map<String, String> 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<String, Object> 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<String, Object> 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<String, Object> claims) {
System.out.println("=== " + title + " ===");
try {
System.out.println(MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(claims));
} catch (Exception e) {
System.out.println(claims);
}
}
}