diff --git a/CHANGES.md b/CHANGES.md index ca2ff1b78..3f8e27f5e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ ## 6.1.6 +Initial installation now requires a one-time, cryptographically secure setup token printed to the server log; bootstrap access closes after the first administrator is created. + A maintenance release. Users of 6.1.5 and earlier are encouraged to upgrade. ### Behaviour changes worth reading before upgrading diff --git a/app/src/main/java/org/apache/roller/weblogger/business/startup/DatabaseInstaller.java b/app/src/main/java/org/apache/roller/weblogger/business/startup/DatabaseInstaller.java index 684a8aaa3..d0892c612 100644 --- a/app/src/main/java/org/apache/roller/weblogger/business/startup/DatabaseInstaller.java +++ b/app/src/main/java/org/apache/roller/weblogger/business/startup/DatabaseInstaller.java @@ -48,6 +48,9 @@ public class DatabaseInstaller { // the name of the property which holds the dbversion value private static final String DBVERSION_PROP = "roller.database.version"; + // Update this when adding a database or data migration step below. + private static final int LATEST_DATABASE_UPGRADE_VERSION = 610; + public DatabaseInstaller(DatabaseProvider dbProvider, DatabaseScriptProvider scriptProvider) { db = dbProvider; @@ -123,9 +126,12 @@ public boolean isUpgradeRequired() { } return false; - } else { - return databaseVersion < desiredVersion; } + + // A product release does not always change the database. Do not send + // administrators through the upgrade UI for a version-only change. + int requiredDatabaseVersion = Math.min(desiredVersion, LATEST_DATABASE_UPGRADE_VERSION); + return databaseVersion < requiredDatabaseVersion; } 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 a3dfb930b..22bda7afb 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.BootstrapSecurity; import org.apache.roller.weblogger.util.Reflection; import org.apache.roller.weblogger.util.cache.CacheManager; import org.apache.velocity.runtime.RuntimeSingleton; @@ -157,6 +158,10 @@ public void contextInitialized(ServletContextEvent sce) { return; } + if (!WebloggerStartup.isPrepared()) { + BootstrapSecurity.start(); + } + final boolean ittest = "ittest".equals(WebloggerConfig.getProperty("installation.type")); // if preparation failed or is incomplete then we are done, @@ -186,6 +191,21 @@ public void contextInitialized(ServletContextEvent sce) { // trigger initialization process weblogger = WebloggerFactory.getWeblogger(); weblogger.initialize(); + try { + org.apache.roller.weblogger.pojos.RuntimeConfigProperty marker = + weblogger.getPropertiesManager().getProperty(BootstrapSecurity.COMPLETION_PROPERTY); + if (marker != null && "true".equalsIgnoreCase(marker.getValue())) { + BootstrapSecurity.complete(); + } else if (weblogger.getUserManager().getUserCount() > 0) { + weblogger.getPropertiesManager().saveProperty(new org.apache.roller.weblogger.pojos.RuntimeConfigProperty(BootstrapSecurity.COMPLETION_PROPERTY, "true")); + weblogger.flush(); + BootstrapSecurity.complete(); + } else { + BootstrapSecurity.start(); + } + } catch (WebloggerException ignored) { + BootstrapSecurity.start(); + } } catch (BootstrapException ex) { log.fatal("Roller Weblogger bootstrap failed", ex); diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/core/filters/BootstrapFilter.java b/app/src/main/java/org/apache/roller/weblogger/ui/core/filters/BootstrapFilter.java index 2ca3da9c4..a9d20c228 100644 --- a/app/src/main/java/org/apache/roller/weblogger/ui/core/filters/BootstrapFilter.java +++ b/app/src/main/java/org/apache/roller/weblogger/ui/core/filters/BootstrapFilter.java @@ -77,6 +77,8 @@ private boolean isInstallUrl(String uri) { uri.endsWith("bootstrap.rol") || uri.endsWith("create.rol") || uri.endsWith("upgrade.rol") + || uri.endsWith("bootstrap-token.rol") + || uri.endsWith("bootstrap-token!redeem.rol") || uri.endsWith(".js") || uri.endsWith(".css"))); } diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/core/filters/BootstrapSecurityFilter.java b/app/src/main/java/org/apache/roller/weblogger/ui/core/filters/BootstrapSecurityFilter.java new file mode 100644 index 000000000..32b63922c --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/ui/core/filters/BootstrapSecurityFilter.java @@ -0,0 +1,29 @@ +package org.apache.roller.weblogger.ui.core.filters; + +import java.io.IOException; +import javax.servlet.*; +import javax.servlet.http.*; +import org.apache.roller.weblogger.business.WebloggerFactory; +import org.apache.roller.weblogger.ui.core.security.BootstrapSecurity; + +/** Prevents anonymous access to installer and first-user actions. */ +public class BootstrapSecurityFilter implements Filter { + public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { + HttpServletRequest r = (HttpServletRequest) req; + HttpServletResponse p = (HttpServletResponse) res; + String uri = r.getRequestURI(); + boolean tokenPage = uri != null && (uri.endsWith("/bootstrap-token.rol") + || uri.endsWith("/bootstrap-token!redeem.rol")); + boolean installer = uri != null && (tokenPage || uri.contains("/roller-ui/install/") + || uri.endsWith("/roller-ui/register.rol") + || uri.endsWith("/roller-ui/register!save.rol") + || uri.endsWith("/roller-ui/setup.rol")); + if (installer && !BootstrapSecurity.isCompleted()) { + if (tokenPage) { chain.doFilter(req, res); return; } + if (!BootstrapSecurity.isValid(r)) { p.sendRedirect(r.getContextPath() + "/roller-ui/bootstrap-token.rol"); return; } + } + chain.doFilter(req, res); + } + public void init(FilterConfig c) { } + public void destroy() { } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/core/security/BasicUserAutoProvision.java b/app/src/main/java/org/apache/roller/weblogger/ui/core/security/BasicUserAutoProvision.java index e486d02c9..d526e06bc 100644 --- a/app/src/main/java/org/apache/roller/weblogger/ui/core/security/BasicUserAutoProvision.java +++ b/app/src/main/java/org/apache/roller/weblogger/ui/core/security/BasicUserAutoProvision.java @@ -45,6 +45,7 @@ public class BasicUserAutoProvision implements AutoProvision { */ @Override public boolean execute(HttpServletRequest request) { + if (!BootstrapSecurity.isCompleted()) return false; User ud = CustomUserRegistry.getUserDetailsFromAuthentication(request); if (hasNecessaryFields(ud)) { diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/core/security/BootstrapSecurity.java b/app/src/main/java/org/apache/roller/weblogger/ui/core/security/BootstrapSecurity.java new file mode 100644 index 000000000..7fa6ed534 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/ui/core/security/BootstrapSecurity.java @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * 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.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; +import javax.servlet.http.HttpServletRequest; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +/** Process-scoped gate for the unauthenticated installation flow. */ +public final class BootstrapSecurity { + + public static final String COMPLETION_PROPERTY = "bootstrap.completed"; + private static final Log LOG = LogFactory.getLog(BootstrapSecurity.class); + private static final long LIFETIME_MS = 60 * 60 * 1000L; + private static final String SESSION = BootstrapSecurity.class.getName() + ".grant"; + private static byte[] digest; + private static long expires; + private static volatile boolean completed; + private static final ThreadLocal INITIAL = new ThreadLocal<>(); + private BootstrapSecurity() { + } + + public static synchronized void start() { + if (completed || digest != null) { + return; + } + byte[] raw = new byte[32]; + new SecureRandom().nextBytes(raw); + String token = Base64.getUrlEncoder().withoutPadding().encodeToString(raw); + digest = sha256(token); + expires = System.currentTimeMillis() + LIFETIME_MS; + LOG.warn(box( + "ROLLER INITIAL SETUP REQUIRED", + "", + "Open Roller in a web browser.", + "You will be redirected to the secure initial-setup page.", + "", + "Enter this one-time setup token (expires in 60 minutes):", + token)); + } + + public static boolean isCompleted() { + return completed; + } + + public static void complete() { + completed = true; + digest = null; + } + + public static void beginInitialAdmin() { + INITIAL.set(Boolean.TRUE); + } + + public static void endInitialAdmin() { + INITIAL.remove(); + } + + public static boolean initialAdminScope() { + return Boolean.TRUE.equals(INITIAL.get()); + } + + public static boolean isValid(HttpServletRequest request) { + Object grant = request.getSession(false) == null ? null : request.getSession(false).getAttribute(SESSION); + return !completed && grant instanceof Long && ((Long) grant) >= System.currentTimeMillis(); + } + + public static synchronized boolean redeem(HttpServletRequest request, String token) { + if (completed || digest == null || token == null || System.currentTimeMillis() > expires) { + return false; + } + byte[] supplied = sha256(token); + if (!MessageDigest.isEqual(digest, supplied)) { + return false; + } + request.getSession(true).setAttribute(SESSION, System.currentTimeMillis() + LIFETIME_MS); + digest = null; + return true; + } + + private static byte[] sha256(String value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static String box(String... lines) { + int width = 0; + for (String line : lines) { + width = Math.max(width, line.length()); + } + + String border = "+" + "-".repeat(width + 2) + "+"; + StringBuilder message = new StringBuilder("\n").append(border); + for (String line : lines) { + message.append("\n| ").append(line) + .append(" ".repeat(width - line.length())).append(" |"); + } + return message.append("\n").append(border).toString(); + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/BootstrapToken.java b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/BootstrapToken.java new file mode 100644 index 000000000..0d4ce17bc --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/BootstrapToken.java @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * 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.struts2.core; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.roller.weblogger.ui.core.security.BootstrapSecurity; +import org.apache.roller.weblogger.ui.struts2.util.UIAction; +import org.apache.struts2.interceptor.ServletRequestAware; +import org.apache.struts2.interceptor.ServletResponseAware; + +public class BootstrapToken extends UIAction implements ServletRequestAware, ServletResponseAware { + + private static final Log LOG = LogFactory.getLog(BootstrapToken.class); + + private HttpServletRequest request; + private HttpServletResponse response; + private String token; + + public BootstrapToken() { + this.pageTitle = "installer.bootstrap.pageTitle"; + } + + public String execute() { + setResponseHeaders(); + LOG.info("Roller is waiting for an administrator to submit the one-time setup token; " + + "database setup will not continue until the token is accepted. Setup page: " + + request.getRequestURL()); + return INPUT; + } + + public String redeem() { + setResponseHeaders(); + if (!"POST".equalsIgnoreCase(request.getMethod())) { + addActionError(getText("installer.bootstrap.postRequired")); + return INPUT; + } + if (BootstrapSecurity.redeem(request, token == null ? null : token.trim())) { + LOG.info("One-time setup token accepted; continuing database setup."); + return SUCCESS; + } + addActionError(getText("installer.bootstrap.invalidToken")); + return INPUT; + } + + private void setResponseHeaders() { + response.setHeader("Cache-Control", "no-store"); + response.setHeader("Referrer-Policy", "no-referrer"); + } + + public void setToken(String token) { + this.token = token; + } + + public void setServletRequest(HttpServletRequest request) { + this.request = request; + } + + public void setServletResponse(HttpServletResponse response) { + this.response = response; + } + + public boolean isUserRequired() { + return false; + } + + public boolean isWeblogRequired() { + return false; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/Install.java b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/Install.java index feed9b0b6..9fffa8a84 100644 --- a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/Install.java +++ b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/Install.java @@ -32,6 +32,8 @@ import org.apache.roller.weblogger.config.WebloggerConfig; import org.apache.roller.weblogger.ui.struts2.util.UIAction; import org.springframework.beans.FatalBeanException; +import org.apache.struts2.ServletActionContext; +import org.apache.roller.weblogger.ui.core.security.BootstrapSecurity; /** @@ -76,7 +78,6 @@ public boolean isWeblogRequired() { @Override public String execute() { - if (WebloggerFactory.isBootstrapped()) { return SUCCESS; } @@ -114,6 +115,7 @@ public String execute() { public String create() { + if (!BootstrapSecurity.isValid(ServletActionContext.getRequest())) return BOOTSTRAP; if (WebloggerFactory.isBootstrapped()) { return SUCCESS; @@ -134,6 +136,7 @@ public String create() { public String upgrade() { + if (!BootstrapSecurity.isValid(ServletActionContext.getRequest())) return BOOTSTRAP; if (WebloggerFactory.isBootstrapped()) { return SUCCESS; @@ -154,6 +157,7 @@ public String upgrade() { public String bootstrap() { + if (!BootstrapSecurity.isValid(ServletActionContext.getRequest())) return BOOTSTRAP; log.info("ENTERING"); if (WebloggerFactory.isBootstrapped()) { diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/Register.java b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/Register.java index 1d8f6628a..8d9c85050 100644 --- a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/Register.java +++ b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/core/Register.java @@ -33,8 +33,10 @@ import org.apache.roller.weblogger.config.WebloggerConfig; import org.apache.roller.weblogger.config.WebloggerRuntimeConfig; import org.apache.roller.weblogger.pojos.User; +import org.apache.roller.weblogger.pojos.RuntimeConfigProperty; import org.apache.roller.weblogger.ui.core.RollerSession; import org.apache.roller.weblogger.ui.core.security.CustomUserRegistry; +import org.apache.roller.weblogger.ui.core.security.BootstrapSecurity; import org.apache.roller.weblogger.ui.struts2.util.UIAction; import org.apache.roller.weblogger.util.MailUtil; import org.apache.struts2.convention.annotation.AllowedMethods; @@ -170,6 +172,14 @@ public String execute() { public String save() { + if (!BootstrapSecurity.isCompleted() && !BootstrapSecurity.isValid(getServletRequest())) { + return DISABLED_RETURN_CODE; + } + if (!BootstrapSecurity.isCompleted() + && !WebloggerConfig.getBooleanProperty("users.firstUserAdmin")) { + addError("Initial administrator creation is disabled by users.firstUserAdmin"); + return DISABLED_RETURN_CODE; + } // if registration is disabled, then don't allow registration try { @@ -187,6 +197,7 @@ public String save() { if (!hasActionErrors()) { try { + if (!BootstrapSecurity.isCompleted()) BootstrapSecurity.beginInitialAdmin(); UserManager mgr = WebloggerFactory.getWeblogger().getUserManager(); @@ -228,8 +239,11 @@ public String save() { // save new user mgr.addUser(ud); + WebloggerFactory.getWeblogger().getPropertiesManager().saveProperty( + new RuntimeConfigProperty(BootstrapSecurity.COMPLETION_PROPERTY, "true")); WebloggerFactory.getWeblogger().flush(); + if (!BootstrapSecurity.isCompleted()) BootstrapSecurity.complete(); // now send activation email if necessary sendActivationMailIfNeeded(ud, activationEnabled); @@ -248,6 +262,8 @@ public String save() { } catch (WebloggerException ex) { log.error("Error adding new user", ex); addError("generic.error.check.logs"); + } finally { + BootstrapSecurity.endInitialAdmin(); } } diff --git a/app/src/main/resources/ApplicationResources.properties b/app/src/main/resources/ApplicationResources.properties index 9e6a9e957..1aba85d74 100644 --- a/app/src/main/resources/ApplicationResources.properties +++ b/app/src/main/resources/ApplicationResources.properties @@ -585,6 +585,16 @@ inviteMember.disabled=invitations disabled installer.bannerTitleLeft=Apache Roller installer.bannerTitleRight=Auto-Installer +# secure initial setup +installer.bootstrap.pageTitle=Roller initial setup +installer.bootstrap.heading=Secure initial setup +installer.bootstrap.instructions=Enter the one-time setup token printed in the Roller server log. +installer.bootstrap.tokenLabel=Setup token +installer.bootstrap.tokenHelp=The token expires 60 minutes after Roller starts and can only be used once. +installer.bootstrap.submit=Continue +installer.bootstrap.postRequired=The setup token must be submitted using this form. +installer.bootstrap.invalidToken=The setup token is invalid or has expired. + # database error installer.error.connection.pageTitle=Database connection error installer.cannotConnectToDatabase=Cannot connect to database diff --git a/app/src/main/resources/struts.xml b/app/src/main/resources/struts.xml index d16b8346b..ea14c602a 100644 --- a/app/src/main/resources/struts.xml +++ b/app/src/main/resources/struts.xml @@ -138,6 +138,16 @@ .Welcome activate,execute,save + + + .BootstrapToken + + install + /roller-ui/install + + execute,redeem + diff --git a/app/src/main/webapp/WEB-INF/jsps/core/BootstrapToken.jsp b/app/src/main/webapp/WEB-INF/jsps/core/BootstrapToken.jsp new file mode 100644 index 000000000..191499a26 --- /dev/null +++ b/app/src/main/webapp/WEB-INF/jsps/core/BootstrapToken.jsp @@ -0,0 +1,25 @@ +<%@ include file="/WEB-INF/jsps/taglibs-struts2.jsp" %> + +
+
+

+

+ + + +
+ + +

+
+
+ +
+
+
+
diff --git a/app/src/main/webapp/WEB-INF/tiles.xml b/app/src/main/webapp/WEB-INF/tiles.xml index f25bb52e1..d3750ef86 100644 --- a/app/src/main/webapp/WEB-INF/tiles.xml +++ b/app/src/main/webapp/WEB-INF/tiles.xml @@ -142,6 +142,11 @@ + + + + + diff --git a/app/src/main/webapp/WEB-INF/web.xml b/app/src/main/webapp/WEB-INF/web.xml index 31539fc89..8b1b550e3 100644 --- a/app/src/main/webapp/WEB-INF/web.xml +++ b/app/src/main/webapp/WEB-INF/web.xml @@ -44,6 +44,10 @@ BootstrapFilter org.apache.roller.weblogger.ui.core.filters.BootstrapFilter + + BootstrapSecurityFilter + org.apache.roller.weblogger.ui.core.filters.BootstrapSecurityFilter + XmlRpcEnabledFilter @@ -129,6 +133,15 @@ /* REQUEST + + BootstrapSecurityFilter + /* + REQUEST + FORWARD + INCLUDE + ERROR + ASYNC + diff --git a/app/src/test/java/org/apache/roller/weblogger/business/startup/DatabaseInstallerUpgradeTest.java b/app/src/test/java/org/apache/roller/weblogger/business/startup/DatabaseInstallerUpgradeTest.java index 94dbb0129..0250ad5a0 100644 --- a/app/src/test/java/org/apache/roller/weblogger/business/startup/DatabaseInstallerUpgradeTest.java +++ b/app/src/test/java/org/apache/roller/weblogger/business/startup/DatabaseInstallerUpgradeTest.java @@ -14,6 +14,41 @@ import static org.junit.jupiter.api.Assertions.*; class DatabaseInstallerUpgradeTest { + + @Test + void patchReleaseWithoutDatabaseChangesDoesNotRequireUpgrade() throws Exception { + DatabaseProvider db = mock(DatabaseProvider.class); + Connection con = mock(Connection.class); + Statement query = mock(Statement.class); + ResultSet rows = mock(ResultSet.class); + when(db.getConnection()).thenReturn(con); + when(con.createStatement()).thenReturn(query); + when(query.executeQuery(anyString())).thenReturn(rows); + when(rows.next()).thenReturn(true); + when(rows.getString(1)).thenReturn("615"); + + DatabaseInstaller installer = new DatabaseInstaller(db, mock(DatabaseScriptProvider.class)); + + assertFalse(installer.isUpgradeRequired()); + } + + @Test + void pendingDatabaseMigrationRequiresUpgrade() throws Exception { + DatabaseProvider db = mock(DatabaseProvider.class); + Connection con = mock(Connection.class); + Statement query = mock(Statement.class); + ResultSet rows = mock(ResultSet.class); + when(db.getConnection()).thenReturn(con); + when(con.createStatement()).thenReturn(query); + when(query.executeQuery(anyString())).thenReturn(rows); + when(rows.next()).thenReturn(true); + when(rows.getString(1)).thenReturn("520"); + + DatabaseInstaller installer = new DatabaseInstaller(db, mock(DatabaseScriptProvider.class)); + + assertTrue(installer.isUpgradeRequired()); + } + @Test void versionOnlyUpgradeReportsCompletion() throws Exception { DatabaseProvider db = mock(DatabaseProvider.class); diff --git a/app/src/test/java/org/apache/roller/weblogger/ui/core/security/BootstrapSecurityTest.java b/app/src/test/java/org/apache/roller/weblogger/ui/core/security/BootstrapSecurityTest.java new file mode 100644 index 000000000..395d1804b --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/ui/core/security/BootstrapSecurityTest.java @@ -0,0 +1,11 @@ +package org.apache.roller.weblogger.ui.core.security; +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; +class BootstrapSecurityTest { + @Test void completionClosesBootstrapGate() { + BootstrapSecurity.start(); + assertFalse(BootstrapSecurity.isCompleted()); + BootstrapSecurity.complete(); + assertTrue(BootstrapSecurity.isCompleted()); + } +} diff --git a/docs/roller-install-guide.adoc b/docs/roller-install-guide.adoc index 651296047..d18581205 100644 --- a/docs/roller-install-guide.adoc +++ b/docs/roller-install-guide.adoc @@ -791,6 +791,12 @@ error type which the automated installation process is coded to ignore. == Upgrading Roller +=== Initial setup security token + +For a new installation Roller generates a cryptographically random, one-time setup token during startup and prints it to the Roller server log. Open `/roller-ui/bootstrap-token.rol` over HTTPS (or a secured local tunnel) and enter the token before opening the installer or registering the first administrator. The token is accepted only in the POST body, expires after 60 minutes, and is consumed on successful entry. Restart Roller to obtain a new token if it expires or the browser session is lost. The token is never included in a URL or Roller page. + +The token authorizes the installer and first-user flow for the current browser session only. Run database creation or upgrade on one setup node at a time. After the first administrator is committed, bootstrap access is permanently disabled, including after deleting or disabling that account. Existing installations with users continue to use their normal login and registration settings. + This section describes how to upgrade an existing Roller installation to the latest release of Roller by shutting down, backing up and then following the installation instructions with a couple of key exceptions. diff --git a/it-selenium/pom.xml b/it-selenium/pom.xml index fabe549a5..9544eb12f 100644 --- a/it-selenium/pom.xml +++ b/it-selenium/pom.xml @@ -32,7 +32,7 @@ roller-selenium-tests war - diff --git a/it-selenium/src/test/java/org/apache/roller/selenium/InitialLoginTestIT.java b/it-selenium/src/test/java/org/apache/roller/selenium/InitialLoginTestIT.java index a5de111cf..692d1a025 100644 --- a/it-selenium/src/test/java/org/apache/roller/selenium/InitialLoginTestIT.java +++ b/it-selenium/src/test/java/org/apache/roller/selenium/InitialLoginTestIT.java @@ -18,8 +18,10 @@ package org.apache.roller.selenium; import java.awt.GraphicsEnvironment; +import java.nio.file.Paths; import java.time.Duration; import java.time.temporal.ChronoUnit; +import org.apache.roller.selenium.core.BootstrapTokenPage; import org.apache.roller.selenium.core.CreateWeblogPage; import org.apache.roller.selenium.core.LoginPage; import org.apache.roller.selenium.core.MainMenuPage; @@ -66,14 +68,15 @@ public void setUp() throws Exception { driver.manage().timeouts().implicitlyWait(Duration.of(5, ChronoUnit.SECONDS)) .pageLoadTimeout(Duration.of(5, ChronoUnit.SECONDS)) .scriptTimeout(Duration.of(5, ChronoUnit.SECONDS)); - baseUrl = "http://localhost:8080/roller/"; + baseUrl = System.getProperty("roller.test.baseUrl", "http://localhost:8080/roller/"); } @Test public void testInitialLogin() throws Exception { // create new user and first blog driver.get(baseUrl); - SetupPage sp = new SetupPage(driver); + SetupPage sp = new BootstrapTokenPage(driver).unlock(Paths.get( + System.getProperty("roller.test.logFile", "logs/roller.log"))); RegisterPage rp = sp.createNewUser(); WelcomePage wp = rp.submitUserRegistration("bsmith", "Bob Smith", "bsmith@email.com", "roller123"); diff --git a/it-selenium/src/test/java/org/apache/roller/selenium/core/BootstrapTokenPage.java b/it-selenium/src/test/java/org/apache/roller/selenium/core/BootstrapTokenPage.java new file mode 100644 index 000000000..9beb797e0 --- /dev/null +++ b/it-selenium/src/test/java/org/apache/roller/selenium/core/BootstrapTokenPage.java @@ -0,0 +1,71 @@ +/* + * 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.selenium.core; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.roller.selenium.AbstractRollerPage; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.support.ui.ExpectedConditions; +import org.openqa.selenium.support.ui.WebDriverWait; + +/** The operator-only token step before first-user registration. */ +public class BootstrapTokenPage extends AbstractRollerPage { + + private static final Pattern TOKEN = Pattern.compile( + "Enter this one-time setup token[^\\r\\n]*\\R\\| ([A-Za-z0-9_-]{43}) +\\|"); + + public BootstrapTokenPage(WebDriver driver) { + this.driver = driver; + verifyPageTitle("Roller initial setup"); + } + + public SetupPage unlock(Path serverLog) { + // Read the same server-side log as an operator; never print its token. + // Logging is asynchronous, so allow time for the startup message to flush. + String token = new WebDriverWait(driver, Duration.ofSeconds(10)) + .withMessage("No initial setup token found in " + serverLog) + .until(ignored -> readToken(serverLog)); + setFieldValue("setup-token", token); + clickById("setup-token-submit"); + new WebDriverWait(driver, Duration.ofSeconds(10)).until( + ExpectedConditions.titleIs("Front Page: Welcome to Roller!")); + return new SetupPage(driver); + } + + private static String readToken(Path serverLog) { + try { + return Files.exists(serverLog) ? latestToken(Files.readString(serverLog)) : null; + } catch (IOException ex) { + throw new IllegalStateException("Cannot read Roller server log: " + serverLog, ex); + } + } + + static String latestToken(String log) { + Matcher matcher = TOKEN.matcher(log); + String token = null; + while (matcher.find()) { + token = matcher.group(1); + } + return token; + } +} diff --git a/it-selenium/src/test/java/org/apache/roller/selenium/core/BootstrapTokenPageTest.java b/it-selenium/src/test/java/org/apache/roller/selenium/core/BootstrapTokenPageTest.java new file mode 100644 index 000000000..52a182a6e --- /dev/null +++ b/it-selenium/src/test/java/org/apache/roller/selenium/core/BootstrapTokenPageTest.java @@ -0,0 +1,46 @@ +/* + * 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.selenium.core; + +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class BootstrapTokenPageTest { + + @Test + public void readsLatestBoxedToken() { + String first = "a".repeat(43); + String latest = "_-" + "b".repeat(41); + String log = box(first, "\n") + box(latest, "\r\n"); + assertEquals(latest, BootstrapTokenPage.latestToken(log)); + } + + @Test + public void ignoresUnrelatedOrIncompleteLogMessages() { + assertNull(BootstrapTokenPage.latestToken("| " + "a".repeat(43) + " |\n")); + assertNull(BootstrapTokenPage.latestToken( + "Enter this one-time setup token (expires in 60 minutes): |\n| partial")); + } + + private static String box(String token, String newline) { + return "WARN BootstrapSecurity -" + newline + + "| Enter this one-time setup token (expires in 60 minutes): |" + newline + + "| " + token + " |" + newline; + } +}