Browse Source

SONAR-14586 Add a new filter to redirect system administrators to reset admin password form

tags/8.8.0.42792
Wouter Admiraal 3 years ago
parent
commit
66249573d7

+ 1
- 0
server/sonar-webserver-auth/src/main/java/org/sonar/server/authentication/AuthenticationModule.java View File

@@ -34,6 +34,7 @@ public class AuthenticationModule extends Module {
CredentialsAuthentication.class,
CredentialsExternalAuthentication.class,
CredentialsLocalAuthentication.class,
DefaultAdminCredentialsVerifierFilter.class,
HttpHeadersAuthentication.class,
IdentityProviderRepository.class,
InitFilter.class,

+ 2
- 74
server/sonar-webserver-auth/src/main/java/org/sonar/server/authentication/DefaultAdminCredentialsVerifier.java View File

@@ -19,80 +19,8 @@
*/
package org.sonar.server.authentication;

import org.picocontainer.Startable;
import org.sonar.api.utils.log.Logger;
import org.sonar.api.utils.log.Loggers;
import org.sonar.db.DbClient;
import org.sonar.db.DbSession;
import org.sonar.db.user.UserDto;
import org.sonar.server.authentication.event.AuthenticationEvent;
import org.sonar.server.authentication.event.AuthenticationException;
import org.sonar.server.notification.NotificationManager;
public interface DefaultAdminCredentialsVerifier {

import static org.sonar.server.log.ServerProcessLogging.STARTUP_LOGGER_NAME;
import static org.sonar.server.property.InternalProperties.DEFAULT_ADMIN_CREDENTIAL_USAGE_EMAIL;
boolean hasDefaultCredentialUser();

/**
* Detect usage of an active admin account with default credential in order to ask this account to reset its password during authentication.
*/
public class DefaultAdminCredentialsVerifier implements Startable {

private static final Logger LOGGER = Loggers.get(STARTUP_LOGGER_NAME);

private final DbClient dbClient;
private final CredentialsLocalAuthentication localAuthentication;
private final NotificationManager notificationManager;

public DefaultAdminCredentialsVerifier(DbClient dbClient, CredentialsLocalAuthentication localAuthentication, NotificationManager notificationManager) {
this.dbClient = dbClient;
this.localAuthentication = localAuthentication;
this.notificationManager = notificationManager;
}

@Override
public void start() {
try (DbSession session = dbClient.openSession(false)) {
UserDto admin = dbClient.userDao().selectActiveUserByLogin(session, "admin");
if (admin == null || !isDefaultCredentialUser(session, admin)) {
return;
}
addWarningInSonarDotLog();
dbClient.userDao().update(session, admin.setResetPassword(true));
sendEmailToAdmins(session);
session.commit();
}
}

private static void addWarningInSonarDotLog() {
String highlighter = "####################################################################################################################";
String msg = "Default Administrator credentials are still being used. Make sure to change the password or deactivate the account.";

LOGGER.warn(highlighter);
LOGGER.warn(msg);
LOGGER.warn(highlighter);
}

private boolean isDefaultCredentialUser(DbSession dbSession, UserDto user) {
try {
localAuthentication.authenticate(dbSession, user, "admin", AuthenticationEvent.Method.BASIC);
return true;
} catch (AuthenticationException ex) {
return false;
}
}

private void sendEmailToAdmins(DbSession session) {
if (dbClient.internalPropertiesDao().selectByKey(session, DEFAULT_ADMIN_CREDENTIAL_USAGE_EMAIL)
.map(Boolean::parseBoolean)
.orElse(false)) {
return;
}
notificationManager.scheduleForSending(new DefaultAdminCredentialsVerifierNotification());
dbClient.internalPropertiesDao().save(session, DEFAULT_ADMIN_CREDENTIAL_USAGE_EMAIL, Boolean.TRUE.toString());
}

@Override
public void stop() {
// Nothing to do
}
}

+ 96
- 0
server/sonar-webserver-auth/src/main/java/org/sonar/server/authentication/DefaultAdminCredentialsVerifierFilter.java View File

@@ -0,0 +1,96 @@
/*
* SonarQube
* Copyright (C) 2009-2021 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
package org.sonar.server.authentication;

import com.google.common.collect.ImmutableSet;
import java.io.IOException;
import java.util.Set;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.sonar.api.config.Configuration;
import org.sonar.api.web.ServletFilter;
import org.sonar.server.user.ThreadLocalUserSession;

import static org.sonar.api.web.ServletFilter.UrlPattern.Builder.staticResourcePatterns;
import static org.sonar.server.authentication.AuthenticationRedirection.redirectTo;

public class DefaultAdminCredentialsVerifierFilter extends ServletFilter {
private static final String RESET_PASSWORD_PATH = "/account/reset_password";
private static final String CHANGE_ADMIN_PASSWORD_PATH = "/admin/change_admin_password";
// This property is used by Orchestrator to disable this force redirect. It should never be used in production, which
// is why this is not defined in org.sonar.process.ProcessProperties.
private static final String SONAR_FORCE_REDIRECT_DEFAULT_ADMIN_CREDENTIALS = "sonar.forceRedirectOnDefaultAdminCredentials";

private static final Set<String> SKIPPED_URLS = ImmutableSet.of(
RESET_PASSWORD_PATH,
CHANGE_ADMIN_PASSWORD_PATH,
"/batch/*", "/api/*");

private final Configuration config;
private final DefaultAdminCredentialsVerifier defaultAdminCredentialsVerifier;
private final ThreadLocalUserSession userSession;

public DefaultAdminCredentialsVerifierFilter(Configuration config, DefaultAdminCredentialsVerifier defaultAdminCredentialsVerifier, ThreadLocalUserSession userSession) {
this.config = config;
this.defaultAdminCredentialsVerifier = defaultAdminCredentialsVerifier;
this.userSession = userSession;
}

@Override
public UrlPattern doGetPattern() {
return UrlPattern.builder()
.includes("/*")
.excludes(staticResourcePatterns())
.excludes(SKIPPED_URLS)
.build();
}

@Override
public void init(FilterConfig filterConfig) {
// nothing to do
}

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
boolean forceRedirect = config
.getBoolean(SONAR_FORCE_REDIRECT_DEFAULT_ADMIN_CREDENTIALS)
.orElse(true);

if (forceRedirect && userSession.hasSession() && userSession.isLoggedIn()
&& userSession.isSystemAdministrator() && !"admin".equals(userSession.getLogin())
&& defaultAdminCredentialsVerifier.hasDefaultCredentialUser()) {
redirectTo(response, request.getContextPath() + CHANGE_ADMIN_PASSWORD_PATH);
}

chain.doFilter(request, response);
}

@Override
public void destroy() {
// nothing to do
}
}

+ 113
- 0
server/sonar-webserver-auth/src/main/java/org/sonar/server/authentication/DefaultAdminCredentialsVerifierImpl.java View File

@@ -0,0 +1,113 @@
/*
* SonarQube
* Copyright (C) 2009-2021 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
package org.sonar.server.authentication;

import org.picocontainer.Startable;
import org.sonar.api.utils.log.Logger;
import org.sonar.api.utils.log.Loggers;
import org.sonar.db.DbClient;
import org.sonar.db.DbSession;
import org.sonar.db.user.UserDto;
import org.sonar.server.authentication.event.AuthenticationEvent;
import org.sonar.server.authentication.event.AuthenticationException;
import org.sonar.server.notification.NotificationManager;

import static org.sonar.server.log.ServerProcessLogging.STARTUP_LOGGER_NAME;
import static org.sonar.server.property.InternalProperties.DEFAULT_ADMIN_CREDENTIAL_USAGE_EMAIL;

/**
* Detect usage of an active admin account with default credential in order to ask this account to reset its password during authentication.
*/
public class DefaultAdminCredentialsVerifierImpl implements Startable, DefaultAdminCredentialsVerifier {

private static final Logger LOGGER = Loggers.get(STARTUP_LOGGER_NAME);

private final DbClient dbClient;
private final CredentialsLocalAuthentication localAuthentication;
private final NotificationManager notificationManager;

public DefaultAdminCredentialsVerifierImpl(DbClient dbClient, CredentialsLocalAuthentication localAuthentication, NotificationManager notificationManager) {
this.dbClient = dbClient;
this.localAuthentication = localAuthentication;
this.notificationManager = notificationManager;
}

@Override
public void start() {
try (DbSession session = dbClient.openSession(false)) {
UserDto admin = getAdminUser(session);
if (admin == null || !isDefaultCredentialUser(session, admin)) {
return;
}
addWarningInSonarDotLog();
dbClient.userDao().update(session, admin.setResetPassword(true));
sendEmailToAdmins(session);
session.commit();
}
}

public boolean hasDefaultCredentialUser() {
try (DbSession session = dbClient.openSession(false)) {
UserDto admin = getAdminUser(session);
if (admin == null) {
return false;
} else {
return isDefaultCredentialUser(session, admin);
}
}
}

private UserDto getAdminUser(DbSession session) {
return dbClient.userDao().selectActiveUserByLogin(session, "admin");
}

private static void addWarningInSonarDotLog() {
String highlighter = "####################################################################################################################";
String msg = "Default Administrator credentials are still being used. Make sure to change the password or deactivate the account.";

LOGGER.warn(highlighter);
LOGGER.warn(msg);
LOGGER.warn(highlighter);
}

private boolean isDefaultCredentialUser(DbSession dbSession, UserDto user) {
try {
localAuthentication.authenticate(dbSession, user, "admin", AuthenticationEvent.Method.BASIC);
return true;
} catch (AuthenticationException ex) {
return false;
}
}

private void sendEmailToAdmins(DbSession session) {
if (dbClient.internalPropertiesDao().selectByKey(session, DEFAULT_ADMIN_CREDENTIAL_USAGE_EMAIL)
.map(Boolean::parseBoolean)
.orElse(false)) {
return;
}
notificationManager.scheduleForSending(new DefaultAdminCredentialsVerifierNotification());
dbClient.internalPropertiesDao().save(session, DEFAULT_ADMIN_CREDENTIAL_USAGE_EMAIL, Boolean.TRUE.toString());
}

@Override
public void stop() {
// Nothing to do
}
}

+ 169
- 0
server/sonar-webserver-auth/src/test/java/org/sonar/server/authentication/DefaultAdminCredentialsVerifierFilterTest.java View File

@@ -0,0 +1,169 @@
/*
* SonarQube
* Copyright (C) 2009-2021 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
package org.sonar.server.authentication;

import com.tngtech.java.junit.dataprovider.DataProvider;
import com.tngtech.java.junit.dataprovider.DataProviderRunner;
import com.tngtech.java.junit.dataprovider.UseDataProvider;
import java.util.Optional;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.sonar.api.config.Configuration;
import org.sonar.server.user.ThreadLocalUserSession;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;

@RunWith(DataProviderRunner.class)
public class DefaultAdminCredentialsVerifierFilterTest {

private final HttpServletRequest request = mock(HttpServletRequest.class);
private final HttpServletResponse response = mock(HttpServletResponse.class);
private final FilterChain chain = mock(FilterChain.class);
private final Configuration config = mock(Configuration.class);
private final DefaultAdminCredentialsVerifier defaultAdminCredentialsVerifier = mock(DefaultAdminCredentialsVerifier.class);
private final ThreadLocalUserSession session = mock(ThreadLocalUserSession.class);

private final DefaultAdminCredentialsVerifierFilter underTest = new DefaultAdminCredentialsVerifierFilter(config, defaultAdminCredentialsVerifier, session);

@Before
public void before() {
when(request.getRequestURI()).thenReturn("/");
when(request.getContextPath()).thenReturn("");

when(config.getBoolean("sonar.forceRedirectOnDefaultAdminCredentials")).thenReturn(Optional.of(true));
when(defaultAdminCredentialsVerifier.hasDefaultCredentialUser()).thenReturn(true);
when(session.hasSession()).thenReturn(true);
when(session.isLoggedIn()).thenReturn(true);
when(session.isSystemAdministrator()).thenReturn(true);
}

@Test
public void verify_other_methods() {
underTest.init(mock(FilterConfig.class));
underTest.destroy();

verifyNoInteractions(request, response, chain, session);
}

@Test
public void redirect_if_instance_uses_default_admin_credentials() throws Exception {
underTest.doFilter(request, response, chain);

verify(response).sendRedirect("/admin/change_admin_password");
}

@Test
public void redirect_if_instance_uses_default_admin_credentials_and_web_context_configured() throws Exception {
when(request.getContextPath()).thenReturn("/sonarqube");

underTest.doFilter(request, response, chain);

verify(response).sendRedirect("/sonarqube/admin/change_admin_password");
}

@Test
public void redirect_if_request_uri_ends_with_slash() throws Exception {
when(request.getRequestURI()).thenReturn("/projects/");
when(request.getContextPath()).thenReturn("/sonarqube");

underTest.doFilter(request, response, chain);

verify(response).sendRedirect("/sonarqube/admin/change_admin_password");
}

@Test
public void do_not_redirect_if_not_a_system_administrator() throws Exception {
when(session.isSystemAdministrator()).thenReturn(false);

underTest.doFilter(request, response, chain);

verify(response, never()).sendRedirect(any());
}

@Test
public void do_not_redirect_if_not_logged_in() throws Exception {
when(session.isLoggedIn()).thenReturn(false);

underTest.doFilter(request, response, chain);

verify(response, never()).sendRedirect(any());
}

@Test
public void do_not_redirect_if_user_is_admin() throws Exception {
when(session.getLogin()).thenReturn("admin");

underTest.doFilter(request, response, chain);

verify(response, never()).sendRedirect(any());
}

@Test
public void do_not_redirect_if_instance_does_not_use_default_admin_credentials() throws Exception {
when(defaultAdminCredentialsVerifier.hasDefaultCredentialUser()).thenReturn(false);

underTest.doFilter(request, response, chain);

verify(response, never()).sendRedirect(any());
}

@Test
public void do_not_redirect_if_config_says_so() throws Exception {
when(config.getBoolean("sonar.forceRedirectOnDefaultAdminCredentials")).thenReturn(Optional.of(false));

underTest.doFilter(request, response, chain);

verify(response, never()).sendRedirect(any());
}

@Test
@UseDataProvider("skipped_urls")
public void doGetPattern_verify(String urltoSkip) throws Exception {
when(request.getRequestURI()).thenReturn(urltoSkip);
when(request.getContextPath()).thenReturn("");
underTest.doGetPattern().matches(urltoSkip);

verify(response, never()).sendRedirect(any());
}

@DataProvider
public static Object[][] skipped_urls() {
return new Object[][] {
{"/batch/index"},
{"/batch/file"},
{"/api/issues"},
{"/api/issues/"},
{"/api/*"},
{"/admin/change_admin_password"},
{"/account/reset_password"},
};
}

}

server/sonar-webserver-auth/src/test/java/org/sonar/server/authentication/DefaultAdminCredentialsVerifierTest.java → server/sonar-webserver-auth/src/test/java/org/sonar/server/authentication/DefaultAdminCredentialsVerifierImplTest.java View File

@@ -36,7 +36,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.sonar.server.property.InternalProperties.DEFAULT_ADMIN_CREDENTIAL_USAGE_EMAIL;

public class DefaultAdminCredentialsVerifierTest {
public class DefaultAdminCredentialsVerifierImplTest {

private static final String ADMIN_LOGIN = "admin";

@@ -48,13 +48,28 @@ public class DefaultAdminCredentialsVerifierTest {
private final CredentialsLocalAuthentication localAuthentication = new CredentialsLocalAuthentication(db.getDbClient());
private final NotificationManager notificationManager = mock(NotificationManager.class);

private final DefaultAdminCredentialsVerifier underTest = new DefaultAdminCredentialsVerifier(db.getDbClient(), localAuthentication, notificationManager);
private final DefaultAdminCredentialsVerifierImpl underTest = new DefaultAdminCredentialsVerifierImpl(db.getDbClient(), localAuthentication, notificationManager);

@After
public void after() {
underTest.stop();
}

@Test
public void correctly_detect_if_admin_account_is_used_with_default_credential() {
UserDto admin = db.users().insertUser(u -> u.setLogin(ADMIN_LOGIN));
changePassword(admin, "admin");
assertThat(underTest.hasDefaultCredentialUser()).isTrue();

changePassword(admin, "1234");
assertThat(underTest.hasDefaultCredentialUser()).isFalse();
}

@Test
public void does_not_break_if_admin_account_does_not_exist() {
assertThat(underTest.hasDefaultCredentialUser()).isFalse();
}

@Test
public void set_reset_flag_to_true_and_add_log_when_admin_account_with_default_credential_is_detected() {
UserDto admin = db.users().insertUser(u -> u.setLogin(ADMIN_LOGIN));
@@ -64,7 +79,7 @@ public class DefaultAdminCredentialsVerifierTest {

assertThat(db.users().selectUserByLogin(admin.getLogin()).get().isResetPassword()).isTrue();
assertThat(logTester.logs(LoggerLevel.WARN)).contains("Default Administrator credentials are still being used. Make sure to change the password or deactivate the account.");
assertThat(db.getDbClient().internalPropertiesDao().selectByKey(db.getSession(), DEFAULT_ADMIN_CREDENTIAL_USAGE_EMAIL).get()).isEqualTo("true");
assertThat(db.getDbClient().internalPropertiesDao().selectByKey(db.getSession(), DEFAULT_ADMIN_CREDENTIAL_USAGE_EMAIL)).contains("true");
verify(notificationManager).scheduleForSending(any(Notification.class));
}


+ 2
- 0
server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java View File

@@ -52,6 +52,7 @@ import org.sonar.server.almintegration.ws.ImportHelper;
import org.sonar.server.almsettings.MultipleAlmFeatureProvider;
import org.sonar.server.almsettings.ws.AlmSettingsWsModule;
import org.sonar.server.authentication.AuthenticationModule;
import org.sonar.server.authentication.DefaultAdminCredentialsVerifierImpl;
import org.sonar.server.authentication.DefaultAdminCredentialsVerifierNotificationHandler;
import org.sonar.server.authentication.DefaultAdminCredentialsVerifierNotificationTemplate;
import org.sonar.server.authentication.LogOAuthWarning;
@@ -354,6 +355,7 @@ public class PlatformLevel4 extends PlatformLevel {
GitLabModule.class,
LdapModule.class,
SamlModule.class,
DefaultAdminCredentialsVerifierImpl.class,
DefaultAdminCredentialsVerifierNotificationTemplate.class,
DefaultAdminCredentialsVerifierNotificationHandler.class,


+ 2
- 2
server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevelStartup.java View File

@@ -23,7 +23,7 @@ import org.sonar.api.utils.log.Loggers;
import org.sonar.core.platform.EditionProvider;
import org.sonar.core.platform.PlatformEditionProvider;
import org.sonar.server.app.ProcessCommandWrapper;
import org.sonar.server.authentication.DefaultAdminCredentialsVerifier;
import org.sonar.server.authentication.DefaultAdminCredentialsVerifierImpl;
import org.sonar.server.ce.queue.CeQueueCleaner;
import org.sonar.server.es.IndexerStartupTask;
import org.sonar.server.platform.ServerLifecycleNotifier;
@@ -72,7 +72,7 @@ public class PlatformLevelStartup extends PlatformLevel {
RenameDeprecatedPropertyKeys.class,
CeQueueCleaner.class,
UpgradeSuggestionsCleaner.class,
DefaultAdminCredentialsVerifier.class);
DefaultAdminCredentialsVerifierImpl.class);

// RegisterServletFilters makes the WebService engine of Level4 served by the MasterServletFilter, therefore it
// must be started after all the other startup tasks

Loading…
Cancel
Save