fixes nextcloud openid changes
This commit is contained in:
1 parent
e6594f3ad2
commit
8b6008a002
4 files changed
+487
-28
No files matched your search
+2
-1
@@ -8,7 +8,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group = 'de.jottyfan.bico'
|
group = 'de.jottyfan.bico'
|
||||||
version = '0.3.0'
|
version = '0.3.1'
|
||||||
|
|
||||||
description = """BibleClassOrganizer"""
|
description = """BibleClassOrganizer"""
|
||||||
|
|
||||||
@@ -72,6 +72,7 @@ dependencies {
|
|||||||
runtimeOnly 'org.springframework.boot:spring-boot-starter-tomcat'
|
runtimeOnly 'org.springframework.boot:spring-boot-starter-tomcat'
|
||||||
|
|
||||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
|
testImplementation 'com.fasterxml.jackson.core:jackson-databind'
|
||||||
}
|
}
|
||||||
|
|
||||||
test {
|
test {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package de.jottyfan.bico.modules;
|
package de.jottyfan.bico.modules;
|
||||||
|
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
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.security.oauth2.core.user.OAuth2User;
|
||||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
|
|
||||||
@@ -46,46 +48,37 @@ public abstract class CommonController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ModelAttribute("hasBUrole")
|
private Boolean hasRoles(Principal principal, String... requiredRoles) {
|
||||||
public Boolean hasBURole(Principal principal) {
|
if (principal instanceof OAuth2AuthenticationToken token) {
|
||||||
OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) principal;
|
|
||||||
if (token != null) {
|
|
||||||
OAuth2User user = token.getPrincipal();
|
OAuth2User user = token.getPrincipal();
|
||||||
@SuppressWarnings("unchecked")
|
Object rolesAttr = user.getAttributes().get("roles");
|
||||||
List<String> roles = (List<String>) user.getAttributes().get("roles");
|
if (rolesAttr == null && user instanceof OidcUser oidcUser && oidcUser.getUserInfo() != null) {
|
||||||
return roles.contains("Bibelunterricht");
|
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 {
|
} else {
|
||||||
Main.LOGGER.warn("token is null, no roles can be detected");
|
Main.LOGGER.warn("token is null, no roles can be detected");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ModelAttribute("hasBUrole")
|
||||||
|
public Boolean hasBURole(Principal principal) {
|
||||||
|
return hasRoles(principal, "Bibelunterricht");
|
||||||
|
}
|
||||||
|
|
||||||
@ModelAttribute("hasDateRole")
|
@ModelAttribute("hasDateRole")
|
||||||
public Boolean hasDateRole(Principal principal) {
|
public Boolean hasDateRole(Principal principal) {
|
||||||
OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) principal;
|
return hasRoles(principal, "Kinderstunde klein");
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ModelAttribute("hasAnyRole")
|
@ModelAttribute("hasAnyRole")
|
||||||
public Boolean hasAnyRole(Principal principal) {
|
public Boolean hasAnyRole(Principal principal) {
|
||||||
OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) principal;
|
return hasRoles(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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ spring.datasource.password = ${db.password}
|
|||||||
server.servlet.context-path = ${my.context-path:/BiCO}
|
server.servlet.context-path = ${my.context-path:/BiCO}
|
||||||
|
|
||||||
# nextcloud open ID connection
|
# 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.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-id = ${nextcloud.client-id}
|
||||||
spring.security.oauth2.client.registration.nextcloud.client-secret = ${nextcloud.client-secret}
|
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.authorization-grant-type = authorization_code
|
||||||
spring.security.oauth2.client.registration.nextcloud.redirect-uri = ${nextcloud.redirect-uri}
|
spring.security.oauth2.client.registration.nextcloud.redirect-uri = ${nextcloud.redirect-uri}
|
||||||
spring.security.oauth2.client.registration.nextcloud.client-authentication-method = client_secret_post
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user