diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000000..788be24434
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,5 @@
+.git
+docker/postgresql-data
+docker/postgresql-16-data
+docker/roller-data
+it-selenium
diff --git a/Dockerfile b/Dockerfile
index 330b2cb381..487124e8c7 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -23,17 +23,9 @@
FROM maven:3-eclipse-temurin-17 AS builder
-COPY ./docker /project/docker
-
-# Build Apache Roller
-
-WORKDIR /tmp
-RUN apt-get update && apt-get install -y git
-RUN git clone https://github.com/apache/roller.git
-WORKDIR /tmp/roller
-# change to branch/tag you prefer
-RUN git checkout tags/roller-6.1.0; \
-mvn -Duser.home=/builder/home -DskipTests=true -B clean install
+COPY . /project
+WORKDIR /project
+RUN mvn -Duser.home=/builder/home -DskipTests=true -B clean install
# STAGE 2 - PACKAGE ------------------------------------------------
@@ -51,7 +43,7 @@ ARG DATABASE_JDBC_DRIVERCLASS=org.postgresql.Driver
ARG DATABASE_JDBC_CONNECTIONURL=jdbc:postgresql://postgresql/rollerdb
ARG DATABASE_JDBC_USERNAME=scott
ARG DATABASE_JDBC_PASSWORD=tiger
-ARG DATABASE_HOST=postgresql:5434
+ARG DATABASE_HOST=postgresql:5432
ENV STORAGE_ROOT ${STORAGE_ROOT}
ENV DATABASE_JDBC_DRIVERCLASS ${DATABASE_JDBC_DRIVERCLASS}
@@ -63,7 +55,7 @@ ENV DATABASE_HOST ${DATABASE_HOST}
# install Roller WAR as ROOT.war, create data dirs
WORKDIR /usr/local/roller
-COPY --from=builder /tmp/roller/app/target/roller.war /usr/local/tomcat/webapps/ROOT.war
+COPY --from=builder /project/app/target/roller.war /usr/local/tomcat/webapps/ROOT.war
RUN mkdir -p data/mediafiles data/searchindex
# download PostgreSQL and MySQL drivers plus Mail and Activation JARs
@@ -78,8 +70,8 @@ RUN wget https://repo1.maven.org/maven2/org/eclipse/angus/angus-activation/2.0.2
# Add Roller entry-point and go!
-COPY --from=builder /project/docker/entry-point.sh /usr/local/tomcat/bin
-COPY --from=builder /project/docker/wait-for-it.sh /usr/local/tomcat/bin
+COPY docker/entry-point.sh /usr/local/tomcat/bin
+COPY docker/wait-for-it.sh /usr/local/tomcat/bin
RUN chgrp -R 0 /usr/local/tomcat
RUN chmod -R g+rw /usr/local/tomcat
diff --git a/app/pom.xml b/app/pom.xml
index 357120e671..e127402c0d 100644
--- a/app/pom.xml
+++ b/app/pom.xml
@@ -485,6 +485,24 @@ limitations under the License.
+
+ org.springframework.security
+ spring-security-oauth2-client
+ ${spring.security.version}
+
+
+
+ org.springframework.security
+ spring-security-oauth2-jose
+ ${spring.security.version}
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ 2.18.3
+
+
diff --git a/app/src/main/java/org/apache/roller/weblogger/config/AuthMethod.java b/app/src/main/java/org/apache/roller/weblogger/config/AuthMethod.java
index 3c4fa58af1..1d2248b9fc 100644
--- a/app/src/main/java/org/apache/roller/weblogger/config/AuthMethod.java
+++ b/app/src/main/java/org/apache/roller/weblogger/config/AuthMethod.java
@@ -17,11 +17,14 @@
*/
package org.apache.roller.weblogger.config;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
public enum AuthMethod {
ROLLERDB("db"),
LDAP("ldap"),
- OPENID("openid"),
- DB_OPENID("db-openid"),
+ OIDC("oidc"),
+ DB_OIDC("db-oidc"),
CMA("cma");
private final String propertyName;
@@ -34,7 +37,22 @@ public String getPropertyName() {
return propertyName;
}
+ private static final Log log = LogFactory.getLog(AuthMethod.class);
+ private static boolean warnedAboutOpenId;
+
public static AuthMethod getAuthMethod(String propertyName) {
+ // OpenID 2.0 was replaced by OIDC; accept the old property values so
+ // an upgraded install boots instead of failing on every request
+ if ("openid".equals(propertyName) || "db-openid".equals(propertyName)) {
+ AuthMethod replacement = "openid".equals(propertyName) ? OIDC : DB_OIDC;
+ if (!warnedAboutOpenId) {
+ warnedAboutOpenId = true;
+ log.warn("authentication.method=" + propertyName + " is no longer supported and is "
+ + "treated as " + replacement.getPropertyName() + "; update the property and "
+ + "configure an oidc.{id}.* provider registration");
+ }
+ return replacement;
+ }
for (AuthMethod test : AuthMethod.values()) {
if (test.getPropertyName().equals(propertyName)) {
return test;
diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/core/RollerContext.java b/app/src/main/java/org/apache/roller/weblogger/ui/core/RollerContext.java
index 9413bc8caf..65f11a1b0c 100644
--- a/app/src/main/java/org/apache/roller/weblogger/ui/core/RollerContext.java
+++ b/app/src/main/java/org/apache/roller/weblogger/ui/core/RollerContext.java
@@ -45,6 +45,7 @@
import org.apache.roller.weblogger.ui.core.plugins.UIPluginManager;
import org.apache.roller.weblogger.ui.core.plugins.UIPluginManagerImpl;
import org.apache.roller.weblogger.ui.core.security.AutoProvision;
+import org.apache.roller.weblogger.ui.core.security.RollerClientRegistrationRepository;
import org.apache.roller.weblogger.util.Reflection;
import org.apache.roller.weblogger.util.cache.CacheManager;
import org.apache.velocity.runtime.RuntimeSingleton;
@@ -309,7 +310,10 @@ private DelegatingPasswordEncoder createPasswordEncoder() {
// supported encoders
encoders.put("bcrypt", new BCryptPasswordEncoder());
- encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
+ // pbkdf2 stores only salt+hash, so its parameters must stay as they were
+ // when existing passwords were encoded or those passwords stop verifying.
+ // scrypt and argon2 encode their parameters, so they can take v5_8.
+ encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5());
// provided by bouncy castle dependency
encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
@@ -340,6 +344,21 @@ private DelegatingPasswordEncoder createPasswordEncoder() {
}
+ /**
+ * The OIDC client registrations declared in security.xml, or null when
+ * OIDC is not configured.
+ */
+ public static RollerClientRegistrationRepository getClientRegistrationRepository() {
+ ApplicationContext ctx =
+ WebApplicationContextUtils.getRequiredWebApplicationContext(servletContext);
+ try {
+ return ctx.getBean("clientRegistrationRepository", RollerClientRegistrationRepository.class);
+ } catch (NoSuchBeanDefinitionException exc) {
+ log.debug("No clientRegistrationRepository bean in context", exc);
+ return null;
+ }
+ }
+
/**
* Flush user from any caches maintained by security system.
*/
diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/core/RollerSession.java b/app/src/main/java/org/apache/roller/weblogger/ui/core/RollerSession.java
index 959cc3e908..f02fba54b6 100644
--- a/app/src/main/java/org/apache/roller/weblogger/ui/core/RollerSession.java
+++ b/app/src/main/java/org/apache/roller/weblogger/ui/core/RollerSession.java
@@ -34,6 +34,9 @@
import org.apache.roller.weblogger.business.UserManager;
import org.apache.roller.weblogger.pojos.User;
import org.apache.roller.weblogger.ui.core.security.AutoProvision;
+import org.apache.roller.weblogger.ui.core.security.RollerOidcUserService;
+import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
+import org.springframework.security.oauth2.core.oidc.user.OidcUser;
/**
@@ -90,6 +93,15 @@ public static RollerSession getRollerSession(HttpServletRequest request) {
UserManager umgr = WebloggerFactory.getWeblogger().getUserManager();
User user = umgr.getUserByUserName(principal.getName());
+ // For OIDC-authenticated users, look up by OIDC subject
+ if (user == null && principal instanceof OAuth2AuthenticationToken oauthToken) {
+ Object oauthPrincipal = oauthToken.getPrincipal();
+ if (oauthPrincipal instanceof OidcUser oidcUser) {
+ String oidcSubject = RollerOidcUserService.toOidcSubject(oidcUser);
+ user = umgr.getUserByOpenIdUrl(oidcSubject);
+ }
+ }
+
// try one time to auto-provision, only happens if user==null
// which means installation has LDAP enabled in security.xml
if (user == null && WebloggerConfig.getBooleanProperty("users.ldap.autoProvision.enabled")) {
diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/core/security/RollerClientRegistrationRepository.java b/app/src/main/java/org/apache/roller/weblogger/ui/core/security/RollerClientRegistrationRepository.java
new file mode 100644
index 0000000000..d84db0cba1
--- /dev/null
+++ b/app/src/main/java/org/apache/roller/weblogger/ui/core/security/RollerClientRegistrationRepository.java
@@ -0,0 +1,235 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. The ASF licenses this file to You
+ * under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License. For additional information regarding
+ * copyright in this work, please see the NOTICE file in the top level
+ * directory of this distribution.
+ */
+package org.apache.roller.weblogger.ui.core.security;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.apache.roller.weblogger.config.AuthMethod;
+import org.apache.roller.weblogger.config.WebloggerConfig;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
+import org.springframework.security.oauth2.client.registration.ClientRegistrations;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+
+/**
+ * Builds OAuth2/OIDC client registrations from Roller properties.
+ *
+ *
No registrations are served unless {@code authentication.method} is
+ * {@code oidc} or {@code db-oidc}, so configuring providers under another
+ * method does not open the {@code /oauth2/authorization/*} endpoints.
+ *
+ *
OIDC discovery is deferred until first access so the identity provider
+ * does not need to be reachable during application startup. Each provider is
+ * resolved and cached independently under a discovery timeout: one unreachable
+ * provider does not block the others, a failed provider is retried with a
+ * bounded backoff instead of on every request, and concurrent requests do not
+ * pile onto the same discovery (an in-flight provider is simply skipped until
+ * its attempt finishes).
+ *
+ *
Properties follow the pattern:
+ *
+ * oidc.{registrationId}.client-id=...
+ * oidc.{registrationId}.client-secret=...
+ * oidc.{registrationId}.issuer-uri=...
+ * oidc.{registrationId}.client-name=... (optional, defaults to registrationId)
+ * oidc.{registrationId}.scope=openid,profile,email (optional)
+ * oidc.{registrationId}.client-authentication-method=none (optional, for a
+ * public client using PKCE; without it a client-secret is required)
+ *
+ */
+public class RollerClientRegistrationRepository implements ClientRegistrationRepository, Iterable {
+
+ private static final Log log = LogFactory.getLog(RollerClientRegistrationRepository.class);
+ private static final String PREFIX = "oidc.";
+ private static final long RETRY_BACKOFF_MS = 60_000;
+ private static final long DISCOVERY_TIMEOUT_MS = 10_000;
+
+ private final Map resolved = new ConcurrentHashMap<>();
+ private final Map failedAt = new ConcurrentHashMap<>();
+ private final Map inFlight = new ConcurrentHashMap<>();
+
+ @Override
+ public ClientRegistration findByRegistrationId(String registrationId) {
+ if (!oidcEnabled()) {
+ return null;
+ }
+ ClientRegistration registration = resolved.get(registrationId);
+ if (registration != null) {
+ return registration;
+ }
+ // resolve only the requested provider, not the whole configuration
+ String clientId = configuredProviderIds().get(registrationId);
+ if (clientId != null) {
+ resolveProvider(registrationId, clientId);
+ }
+ return resolved.get(registrationId);
+ }
+
+ @Override
+ public Iterator iterator() {
+ return getRegistrations().values().iterator();
+ }
+
+ /** Whether the configured authentication method allows OIDC login at all. */
+ static boolean oidcEnabled() {
+ AuthMethod method = WebloggerConfig.getAuthMethod();
+ return method == AuthMethod.OIDC || method == AuthMethod.DB_OIDC;
+ }
+
+ /**
+ * The successfully resolved registrations, in configuration order. Providers
+ * that have not resolved yet are attempted, unless they failed within the
+ * retry backoff window.
+ */
+ public Map getRegistrations() {
+ if (!oidcEnabled()) {
+ return Collections.emptyMap();
+ }
+
+ Map configured = configuredProviderIds();
+ for (Map.Entry entry : configured.entrySet()) {
+ resolveProvider(entry.getKey(), entry.getValue());
+ }
+
+ // return in configuration order, only what resolved
+ Map result = new LinkedHashMap<>();
+ for (String id : configured.keySet()) {
+ ClientRegistration registration = resolved.get(id);
+ if (registration != null) {
+ result.put(id, registration);
+ }
+ }
+ return Collections.unmodifiableMap(result);
+ }
+
+ /**
+ * Attempts discovery for one provider unless it is already resolved, failed
+ * within the backoff window, or another thread is on it right now.
+ */
+ private void resolveProvider(String id, String clientId) {
+ if (resolved.containsKey(id)) {
+ return;
+ }
+ Long lastFailure = failedAt.get(id);
+ if (lastFailure != null && System.currentTimeMillis() - lastFailure < RETRY_BACKOFF_MS) {
+ return;
+ }
+ if (inFlight.putIfAbsent(id, Boolean.TRUE) != null) {
+ return;
+ }
+ try {
+ // discovery has no timeout hook of its own, so bound the wait here;
+ // an abandoned attempt still occupies its pool thread until the
+ // connection gives up, but request threads stop paying for it
+ ClientRegistration registration = null;
+ try {
+ registration = CompletableFuture.supplyAsync(() -> buildRegistration(id, clientId))
+ .get(DISCOVERY_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+ } catch (TimeoutException e) {
+ log.error("OIDC discovery for provider '" + id + "' timed out after "
+ + DISCOVERY_TIMEOUT_MS + "ms, will retry in " + (RETRY_BACKOFF_MS / 1000) + "s");
+ } catch (Exception e) {
+ log.error("OIDC discovery for provider '" + id + "' failed", e);
+ }
+ if (registration != null) {
+ resolved.put(id, registration);
+ failedAt.remove(id);
+ } else {
+ failedAt.put(id, System.currentTimeMillis());
+ }
+ } finally {
+ inFlight.remove(id);
+ }
+ }
+
+ /** Registration ids that have an {@code oidc..client-id} property set. */
+ static Map configuredProviderIds() {
+ Map registrationIds = new LinkedHashMap<>();
+ Enumeration