diff --git a/changelog/unreleased/SOLR-18233-Strengthen-Basic-Authentication-password-policy.yml b/changelog/unreleased/SOLR-18233-Strengthen-Basic-Authentication-password-policy.yml new file mode 100644 index 000000000000..68d8a849616d --- /dev/null +++ b/changelog/unreleased/SOLR-18233-Strengthen-Basic-Authentication-password-policy.yml @@ -0,0 +1,7 @@ +title: Strengthen Basic Authentication password policy (password must differ from username) and harden template users created by bin/solr auth enable. The check can be temporarily disabled with -Dsolr.security.auth.basicauth.allowuseraspassword=true (env SOLR_SECURITY_AUTH_BASICAUTH_ALLOWUSERASPASSWORD) as an upgrade escape hatch. +type: fixed +authors: + - name: Jan Høydahl +links: + - name: SOLR-18233 + url: https://issues.apache.org/jira/browse/SOLR-18233 diff --git a/solr/core/src/java/org/apache/solr/cli/AuthTool.java b/solr/core/src/java/org/apache/solr/cli/AuthTool.java index 5c19ac300d7f..1e6a29853305 100644 --- a/solr/core/src/java/org/apache/solr/cli/AuthTool.java +++ b/solr/core/src/java/org/apache/solr/cli/AuthTool.java @@ -28,14 +28,17 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.stream.Collectors; import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.Option; import org.apache.commons.cli.Options; import org.apache.lucene.util.Constants; import org.apache.solr.common.cloud.SolrZkClient; +import org.apache.solr.common.util.EnvUtils; import org.apache.solr.core.SolrCore; import org.apache.solr.security.Sha256AuthenticationProvider; import org.apache.zookeeper.KeeperException; @@ -227,8 +230,14 @@ private void handleBasicAuth(CommandLine cli) throws Exception { } while (password.isEmpty()); } - boolean blockUnknown = - Boolean.parseBoolean(cli.getOptionValue(BLOCK_UNKNOWN_OPTION, "true")); + if (username.equals(password) + && !EnvUtils.getPropertyAsBool( + Sha256AuthenticationProvider.ALLOW_USER_AS_PASSWORD_PROP, false)) { + CLIO.err( + "Error: username and password must not be identical." + + " This credential would never authenticate."); + runtime.exit(1); + } String resourceName = "security.json"; final URL resource = SolrCore.class.getClassLoader().getResource(resourceName); @@ -238,7 +247,11 @@ private void handleBasicAuth(CommandLine cli) throws Exception { ObjectMapper mapper = new ObjectMapper(); JsonNode securityJson1 = mapper.readTree(resource.openStream()); - ((ObjectNode) securityJson1).put("blockUnknown", blockUnknown); + // Only override blockUnknown if explicitly passed; otherwise let the template decide + if (cli.hasOption(BLOCK_UNKNOWN_OPTION)) { + boolean blockUnknown = Boolean.parseBoolean(cli.getOptionValue(BLOCK_UNKNOWN_OPTION)); + ((ObjectNode) securityJson1.get("authentication")).put("blockUnknown", blockUnknown); + } JsonNode credentialsNode = securityJson1.get("authentication").get("credentials"); ((ObjectNode) credentialsNode) .put(username, Sha256AuthenticationProvider.getSaltedHashedValue(password)); @@ -284,8 +297,23 @@ private void handleBasicAuth(CommandLine cli) throws Exception { updateIncludeFileEnableAuth(includeFile, basicAuthConfFile); final String successMessage = String.format( - Locale.ROOT, "Successfully enabled basic auth with username [%s].", username); + Locale.ROOT, + "Successfully enabled basic auth with username [%s] assigned to all roles (superadmin, admin, index, search).", + username); echo(successMessage); + if (!updateIncludeFileOnly) { + Map templateUsers = new LinkedHashMap<>(); + templateUsers.put("admin", "admin, index, search"); + templateUsers.put("index", "index, search"); + templateUsers.put("search", "search"); + templateUsers.remove(username); + CLIO.out( + "\nIMPORTANT: The following template users have been created with NO password set" + + " and cannot log in until passwords are assigned:"); + templateUsers.forEach((u, roles) -> CLIO.out(" - " + u + " (roles: " + roles + ")")); + CLIO.out( + "Set their passwords using the Admin UI Security page or the authentication API."); + } return; } case "disable": diff --git a/solr/core/src/java/org/apache/solr/handler/admin/SecurityConfHandler.java b/solr/core/src/java/org/apache/solr/handler/admin/SecurityConfHandler.java index 315397f3e4e4..7e9476faf9eb 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/SecurityConfHandler.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/SecurityConfHandler.java @@ -33,6 +33,7 @@ import org.apache.solr.api.ApiBag; import org.apache.solr.api.ApiBag.ReqHandlerToApi; import org.apache.solr.client.solrj.SolrRequest; +import org.apache.solr.common.SolrErrorWrappingException; import org.apache.solr.common.SolrException; import org.apache.solr.common.SpecProvider; import org.apache.solr.common.params.CommonParams; @@ -135,8 +136,8 @@ private void doEdit( if (out == null) { List> errs = CommandOperation.captureErrors(commandsCopy); if (!errs.isEmpty()) { - rsp.add(CommandOperation.ERR_MSGS, errs); - return; + throw new SolrErrorWrappingException( + SolrException.ErrorCode.BAD_REQUEST, "error processing commands", errs); } log.debug("No edits made"); return; diff --git a/solr/core/src/java/org/apache/solr/security/Sha256AuthenticationProvider.java b/solr/core/src/java/org/apache/solr/security/Sha256AuthenticationProvider.java index 31c38537f695..9ab59fd72c4b 100644 --- a/solr/core/src/java/org/apache/solr/security/Sha256AuthenticationProvider.java +++ b/solr/core/src/java/org/apache/solr/security/Sha256AuthenticationProvider.java @@ -33,6 +33,7 @@ import org.apache.solr.api.AnnotatedApi; import org.apache.solr.api.Api; import org.apache.solr.common.util.CommandOperation; +import org.apache.solr.common.util.EnvUtils; import org.apache.solr.common.util.ValidatingJsonMap; import org.apache.solr.handler.admin.api.ModifyBasicAuthConfigAPI; import org.slf4j.Logger; @@ -43,6 +44,19 @@ public class Sha256AuthenticationProvider static String CANNOT_DELETE_LAST_USER_ERROR = "You cannot delete the last user. At least one user must be configured at all times."; + + /** + * System property (or {@code SOLR_SECURITY_AUTH_BASICAUTH_ALLOWUSERASPASSWORD} environment + * variable) that, when explicitly set to {@code true}, disables the check that rejects a password + * equal to its username, both at login time and when creating or editing a user via the {@code + * set-user} command (UI, API or CLI). This is an escape hatch for users upgrading to 9.11.0 or + * 10.1.0 who still have Basic Auth users with weak passwords equal to the username; it lets them + * keep logging in (and managing those accounts) while they migrate to stronger passwords. + * Defaults to {@code false}. + */ + public static final String ALLOW_USER_AS_PASSWORD_PROP = + "solr.security.auth.basicauth.allowuseraspassword"; + private Map credentials; private String realm; private Map promptHeader; @@ -93,6 +107,11 @@ public void init(Map pluginConfig) { @Override public boolean authenticate(String username, String password) { + if (username != null + && username.equals(password) + && !EnvUtils.getPropertyAsBool(ALLOW_USER_AS_PASSWORD_PROP, false)) { + return false; + } String cred = credentials.get(username); if (cred == null || cred.isEmpty()) return false; cred = cred.trim(); @@ -165,6 +184,11 @@ public Map edit(Map latestConf, List handler.handleRequestBody(badReq, badRsp)); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + assertTrue(ex.getMessage().contains("method is not a valid key for the permission")); handler.close(); } diff --git a/solr/core/src/test/org/apache/solr/security/AuthWithShardHandlerFactoryOverrideTest.java b/solr/core/src/test/org/apache/solr/security/AuthWithShardHandlerFactoryOverrideTest.java index b1dfedc3f787..1c7c2c171696 100644 --- a/solr/core/src/test/org/apache/solr/security/AuthWithShardHandlerFactoryOverrideTest.java +++ b/solr/core/src/test/org/apache/solr/security/AuthWithShardHandlerFactoryOverrideTest.java @@ -38,7 +38,7 @@ public class AuthWithShardHandlerFactoryOverrideTest extends SolrCloudAuthTestCa + " \"authentication\":{\n" + " \"blockUnknown\": true,\n" + " \"class\":\"solr.BasicAuthPlugin\",\n" - + " \"credentials\":{\"solr\":\"EEKn7ywYk5jY8vG9TyqlG2jvYuvh1Q7kCCor6Hqm320= 6zkmjMjkMKyJX6/f0VarEWQujju5BzxZXub6WOrEKCw=\"}\n" + + " \"credentials\":{\"solr\":\"JeRyxP8A3dVWhFgFbf/Eg2RXmuoU8BE5gbNQyxmGAJQ= 6zkmjMjkMKyJX6/f0VarEWQujju5BzxZXub6WOrEKCw=\"}\n" + " },\n" + " \"authorization\":{\n" + " \"class\":\"solr.RuleBasedAuthorizationPlugin\",\n" @@ -56,11 +56,11 @@ public void setupCluster() throws Exception { .withSecurityJson(SECURITY_CONF) .configure(); CollectionAdminRequest.createCollection(COLLECTION, "conf", 4, 1) - .setBasicAuthCredentials("solr", "solr") + .setBasicAuthCredentials("solr", "SolrRocks") .process(cluster.getSolrClient()); CollectionAdminRequest.createAlias(ALIAS, COLLECTION) - .setBasicAuthCredentials("solr", "solr") + .setBasicAuthCredentials("solr", "SolrRocks") .process(cluster.getSolrClient()); cluster.waitForActiveCollection(COLLECTION, 4, 4); @@ -89,7 +89,7 @@ public void collectionTest() throws Exception { for (int i = 0; i < 30; i++) { SolrResponse response = new QueryRequest(params("q", "*:*")) - .setBasicAuthCredentials("solr", "solr") + .setBasicAuthCredentials("solr", "SolrRocks") .process(client, COLLECTION); // likely to be non-null, even if an error occurred assertNotNull(response); @@ -109,7 +109,7 @@ public void aliasTest() throws Exception { for (int i = 0; i < 30; i++) { SolrResponse response = new QueryRequest(params("q", "*:*")) - .setBasicAuthCredentials("solr", "solr") + .setBasicAuthCredentials("solr", "SolrRocks") .process(client, ALIAS); // likely to be non-null, even if an error occurred assertNotNull(response); diff --git a/solr/core/src/test/org/apache/solr/security/BasicAuthOnSingleNodeTest.java b/solr/core/src/test/org/apache/solr/security/BasicAuthOnSingleNodeTest.java index f6f35c37d3bd..26ca70cdf13a 100644 --- a/solr/core/src/test/org/apache/solr/security/BasicAuthOnSingleNodeTest.java +++ b/solr/core/src/test/org/apache/solr/security/BasicAuthOnSingleNodeTest.java @@ -37,7 +37,7 @@ public void setupCluster() throws Exception { .withSecurityJson(STD_CONF) .configure(); CollectionAdminRequest.createCollection(COLLECTION, "conf", 4, 1) - .setBasicAuthCredentials("solr", "solr") + .setBasicAuthCredentials("solr", "SolrRocks") .process(cluster.getSolrClient()); cluster.waitForActiveCollection(COLLECTION, 4, 4); } @@ -60,7 +60,7 @@ public void basicTest() throws Exception { for (int i = 0; i < 30; i++) { assertNotNull( new QueryRequest(params("q", "*:*")) - .setBasicAuthCredentials("solr", "solr") + .setBasicAuthCredentials("solr", "SolrRocks") .process(client, COLLECTION)); } } @@ -106,7 +106,7 @@ public void testDeleteSecurityJsonZnode() throws Exception { + " \"authentication\":{\n" + " \"blockUnknown\": true,\n" + " \"class\":\"solr.BasicAuthPlugin\",\n" - + " \"credentials\":{\"solr\":\"EEKn7ywYk5jY8vG9TyqlG2jvYuvh1Q7kCCor6Hqm320= 6zkmjMjkMKyJX6/f0VarEWQujju5BzxZXub6WOrEKCw=\"}\n" + + " \"credentials\":{\"solr\":\"JeRyxP8A3dVWhFgFbf/Eg2RXmuoU8BE5gbNQyxmGAJQ= 6zkmjMjkMKyJX6/f0VarEWQujju5BzxZXub6WOrEKCw=\"}\n" + " },\n" + " \"authorization\":{\n" + " \"class\":\"solr.RuleBasedAuthorizationPlugin\",\n" diff --git a/solr/core/src/test/org/apache/solr/security/TestSha256AuthenticationProvider.java b/solr/core/src/test/org/apache/solr/security/TestSha256AuthenticationProvider.java index 632fd16b35ad..eec042314665 100644 --- a/solr/core/src/test/org/apache/solr/security/TestSha256AuthenticationProvider.java +++ b/solr/core/src/test/org/apache/solr/security/TestSha256AuthenticationProvider.java @@ -104,6 +104,64 @@ public void testBasicAuthDeleteFinalUser() throws IOException { } } + public void testAuthenticateRejectsUsernameEqualPassword() { + // Simulate a credential store that has the username's own hash as the password + // (e.g. set up before this policy was in effect) and verify authenticate() still rejects it. + String user = "alice"; + String hashedValue = Sha256AuthenticationProvider.getSaltedHashedValue(user); + Map config = new HashMap<>(); + Map credentials = new HashMap<>(); + credentials.put(user, hashedValue); + config.put("credentials", credentials); + + Sha256AuthenticationProvider provider = new Sha256AuthenticationProvider(); + provider.init(config); + assertFalse( + "authenticate() must reject username==password even when hash matches", + provider.authenticate(user, user)); + } + + public void testAllowUserAsPasswordSyspropReenablesLogin() { + String user = "alice"; + String hashedValue = Sha256AuthenticationProvider.getSaltedHashedValue(user); + Map config = new HashMap<>(); + Map credentials = new HashMap<>(); + credentials.put(user, hashedValue); + config.put("credentials", credentials); + + Sha256AuthenticationProvider provider = new Sha256AuthenticationProvider(); + provider.init(config); + + System.setProperty(Sha256AuthenticationProvider.ALLOW_USER_AS_PASSWORD_PROP, "true"); + try { + assertTrue( + "authenticate() must allow username==password when escape-hatch sysprop is true", + provider.authenticate(user, user)); + assertFalse( + "authenticate() must still reject a genuinely wrong password", + provider.authenticate(user, "WrongPassword")); + + // The escape hatch also relaxes set-user, so username==password is accepted there. + Map latestConf = createConfigMap("ignore", "me"); + CommandOperation cmd = new CommandOperation("set-user", Map.of(user, user)); + provider.edit(latestConf, List.of(cmd)); + assertFalse( + "set-user should allow username==password when escape hatch is enabled", cmd.hasError()); + } finally { + System.clearProperty(Sha256AuthenticationProvider.ALLOW_USER_AS_PASSWORD_PROP); + } + } + + public void testSetUserRejectsUsernameEqualPassword() { + Sha256AuthenticationProvider provider = new Sha256AuthenticationProvider(); + provider.init(createConfigMap("ignore", "me")); + Map latestConf = createConfigMap("ignore", "me"); + String user = "bob"; + CommandOperation cmd = new CommandOperation("set-user", Map.of(user, user)); + provider.edit(latestConf, List.of(cmd)); + assertTrue("set-user should report an error when username==password", cmd.hasError()); + } + private Map createConfigMap(String user, String pw) { Map config = new HashMap<>(); Map credentials = new HashMap<>(); diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/basic-authentication-plugin.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/basic-authentication-plugin.adoc index 19536088939e..9f1a062feb6d 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/pages/basic-authentication-plugin.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/basic-authentication-plugin.adoc @@ -24,10 +24,11 @@ To control user permissions, you may need to configure an authorization plugin a == Enable Basic Authentication -To use Basic authentication, you must first create a `security.json` file. -This file and where to put it is described in detail in the section xref:authentication-and-authorization-plugins.adoc#configuring-security-json[Configuring security.json]. +When running in cloud mode, Basic authentication can be enabled from the command line using the `bin/solr auth enable` command, which applies a best-practice security template with pre-configured roles and permissions. +See xref:solr-control-script-reference.adoc#enabling-basic-authentication[Enabling Basic Authentication] in the Solr Control Script Reference for details. -If running in cloud mode, you can use the `bin/solr auth` command-line utility to enable security for a new installation, see: `bin/solr auth --help` for more details. +Alternatively, you can create the `security.json` file manually. +This file and where to put it is described in detail in the section xref:authentication-and-authorization-plugins.adoc#configuring-security-json[Configuring security.json]. For Basic authentication, `security.json` must have an `authentication` block which defines the class being used for authentication. Usernames and passwords could be added when the file is created, or can be added later with the Authentication API, described below. @@ -185,6 +186,7 @@ If users need to be restricted to a specific collection, that can be done with t === Add a User or Edit a Password The `set-user` command allows you to add users and change their passwords. +Passwords must not be identical to the username. For example, the following defines two users and their passwords: [tabs#set-user] diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/solr-control-script-reference.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/solr-control-script-reference.adoc index f507216396be..aae46c7c6cc9 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/pages/solr-control-script-reference.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/solr-control-script-reference.adoc @@ -916,19 +916,23 @@ The `bin/solr` script allows enabling or disabling Authentication, allowing you Currently this command is only available when using SolrCloud mode and must be run on the machine hosting Solr. -For Basic Authentication the script provides https://github.com/apache/solr/blob/main/solr/core/src/resources/security.json[user roles and permission mappings], and maps the created user to the `superadmin` role. +For Basic Authentication the script provides https://github.com/apache/solr/blob/main/solr/core/src/resources/security.json[user roles and permission mappings], and maps the created user to all roles (`superadmin`, `admin`, `index`, `search`). === Enabling Basic Authentication The command `bin/solr auth enable` configures Solr to use Basic Authentication when accessing the User Interface, using `bin/solr` and any API requests. +NOTE: This command currently requires SolrCloud mode — it uploads the generated `security.json` to ZooKeeper so that all nodes pick it up automatically. +For user-managed (standalone) clusters, you must create the `security.json` file manually and place it in each node's Solr home directory. +See xref:basic-authentication-plugin.adoc[] for details. + TIP: For more information about Solr's authentication plugins, see the section xref:securing-solr.adoc[]. For more information on Basic Authentication support specifically, see the section xref:basic-authentication-plugin.adoc[]. The `bin/solr auth enable` command makes several changes to enable Basic Authentication: -* Take the base https://github.com/apache/solr/blob/main/solr/core/resources/security.json[security.json] file, evolves it using `auth` command parameters, and uploads the new file to ZooKeeper. +* Takes the base https://github.com/apache/solr/blob/main/solr/core/src/resources/security.json[security.json] template with best-practice roles and permissions, applies `auth` command parameters, and uploads the result to ZooKeeper. + * Adds two lines to `bin/solr.in.sh` or `bin\solr.in.cmd` to set the authentication type, and the path to `basicAuth.conf`: + @@ -940,6 +944,19 @@ SOLR_AUTHENTICATION_OPTS="-Dsolr.httpclient.config=/path/to/solr-{solr-full-vers ---- * Creates the file `server/solr/basicAuth.conf` to store the credential information that is used with `bin/solr` commands. +In addition to the operator-created user, the command also creates three template users with predefined role assignments. +These users have no password set and cannot log in until passwords are explicitly assigned: + +[cols="1,2",options="header"] +|=== +|Username |Roles +|`admin` |admin, index, search +|`index` |index, search +|`search` |search +|=== + +After enabling Basic Authentication, set passwords for these template users using the Admin UI Security page or the xref:basic-authentication-plugin.adoc#add-a-user-or-edit-a-password[authentication API]. + Here are some example usages: [source,plain] @@ -981,11 +998,14 @@ Either `--credentials` or `--prompt` *must* be specified. + [%autowidth,frame=none] |=== -|Optional |Default: `true` +|Optional |Default: use value from `security.json` template (`false`) |=== + -When `true`, this blocks out access to unauthenticated users from accessing Solr. -When `false`, unauthenticated users will still be able to access Solr, but only for operations not explicitly requiring a user role in the Authorization plugin configuration. +Controls whether unauthenticated requests are blocked. +The default `security.json` template sets `blockUnknown` to `false` because it includes a `RuleBasedAuthorizationPlugin` with fine-grained permissions — unauthenticated users can only access endpoints explicitly granted to the `null` role (by default *health* and *metrics-read*, left open so that load balancers and monitoring tools can operate without credentials). +All other operations require an authenticated user with the appropriate role. ++ +If you want to require authentication for _all_ requests (including health checks and metrics), pass `--block-unknown true` explicitly. `--solr-include-file `:: + diff --git a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc index 627816636c50..4295e141c65b 100644 --- a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc +++ b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc @@ -81,6 +81,13 @@ Solr 10.1 nodes only send and accept the `SolrAuthV2` (v2) header for inter-node Before performing a rolling upgrade to 10.1, ensure no node in the cluster has `solr.pki.sendVersion=v1` set, as those nodes would send the legacy `SolrAuth` header that 10.1 nodes will reject. The `solr.pki.sendVersion` and `solr.pki.acceptVersions` system properties are no longer recognized in 10.1 and can be removed from your configuration. +The Basic Authentication password policy has been strengthened: a password may no longer be equal to its username. +Login attempts where the password equals the username are now rejected, and creating or editing such a user (via the Admin UI, the Authentication API, or `bin/solr auth`) is no longer allowed. + +If you are upgrading and still have Basic Auth users whose password equals their username, you can temporarily re-enable the old behavior by setting the system property `-Dsolr.security.auth.basicauth.allowuseraspassword=true` (or the environment variable `SOLR_SECURITY_AUTH_BASICAUTH_ALLOWUSERASPASSWORD=true`). +When enabled, this escape hatch relaxes both the login-time check and the user creation/editing check, so existing accounts keep working and can still be managed. +It is intended as a temporary measure while you migrate the affected accounts to stronger passwords, and should be removed once that is done. + === SolrJ HttpSolrClient returns; this time as a base class for HttpJettySolrClient and HttpJdkSolrClient. diff --git a/solr/solrj-streaming/src/test/org/apache/solr/client/solrj/io/stream/CloudAuthStreamTest.java b/solr/solrj-streaming/src/test/org/apache/solr/client/solrj/io/stream/CloudAuthStreamTest.java index 225ca994ec34..7b70f29bcf77 100644 --- a/solr/solrj-streaming/src/test/org/apache/solr/client/solrj/io/stream/CloudAuthStreamTest.java +++ b/solr/solrj-streaming/src/test/org/apache/solr/client/solrj/io/stream/CloudAuthStreamTest.java @@ -65,16 +65,24 @@ public class CloudAuthStreamTest extends SolrCloudTestCase { private static String solrUrl = null; + /** + * Helper: every user's password is derived from their username via this method. The password must + * differ from the username (Solr's Basic Auth policy rejects passwords equal to the username). + */ + private static String passwordFor(final String user) { + return user + "_password"; + } + /** * Helper that returns the original {@link SolrRequest} with its original type so it can - * be chained. This method knows that for the purpose of this test, every username is its own - * password + * be chained. This method knows that for the purpose of this test, every user's password is + * derived from their username via {@link #passwordFor}. * * @see SolrRequest#setBasicAuthCredentials */ private static > T setBasicAuthCredentials(T req, String user) { assertNotNull(user); - req.setBasicAuthCredentials(user, user); + req.setBasicAuthCredentials(user, passwordFor(user)); return req; } @@ -82,9 +90,11 @@ private static > T setBasicAuthCredentials(T req, Strin public static void setupCluster() throws Exception { final List users = Arrays.asList(READ_ONLY_USER, WRITE_X_USER, WRITE_Y_USER, ADMIN_USER); - // For simplicity: every user uses a password the same as their name... + // For simplicity: every user's password is derived from their name via passwordFor()... final Map credentials = - users.stream().collect(Collectors.toMap(Function.identity(), s -> getSaltedHashedValue(s))); + users.stream() + .collect( + Collectors.toMap(Function.identity(), s -> getSaltedHashedValue(passwordFor(s)))); // For simplicity: Every user is their own role... final Map roles = @@ -123,7 +133,7 @@ public static void setupCluster() throws Exception { for (String collection : Arrays.asList(COLLECTION_X, COLLECTION_Y)) { CollectionAdminRequest.createCollection(collection, "_default", 2, 2) - .setBasicAuthCredentials(ADMIN_USER, ADMIN_USER) + .setBasicAuthCredentials(ADMIN_USER, passwordFor(ADMIN_USER)) .process(cluster.getSolrClient()); } @@ -246,7 +256,7 @@ public void testEchoStream() throws Exception { params( "qt", "/stream", "expr", "echo(hello world)")); - solrStream.setCredentials(READ_ONLY_USER, READ_ONLY_USER); + solrStream.setCredentials(READ_ONLY_USER, passwordFor(READ_ONLY_USER)); final List tuples = getTuples(solrStream); assertEquals(1, tuples.size()); assertEquals("hello world", tuples.get(0).get("echo")); @@ -295,7 +305,7 @@ public void testSimpleUpdateStream() throws Exception { "/stream", "expr", "update(" + COLLECTION_X + ",batchSize=1," + "tuple(id=42,a_i=1,b_i=5))")); - solrStream.setCredentials(WRITE_X_USER, WRITE_X_USER); + solrStream.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(solrStream); assertEquals(1, tuples.size()); assertEquals(1L, tuples.get(0).get("totalIndexed")); @@ -338,7 +348,7 @@ public void testSimpleUpdateStreamInsufficientCredentials() throws Exception { "expr", "update(" + COLLECTION_X + ",batchSize=1," + "tuple(id=42,a_i=1,b_i=5))")); - solrStream.setCredentials(user, user); + solrStream.setCredentials(user, passwordFor(user)); // NOTE: Can't make any assertions about Exception: SOLR-14226 expectThrows( @@ -361,7 +371,7 @@ public void testIndirectUpdateStream() throws Exception { "/stream", "expr", "update(" + COLLECTION_X + ",batchSize=1," + "tuple(id=42,a_i=1,b_i=5))")); - solrStream.setCredentials(WRITE_X_USER, WRITE_X_USER); + solrStream.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(solrStream); assertEquals(1, tuples.size()); assertEquals(1L, tuples.get(0).get("totalIndexed")); @@ -390,7 +400,7 @@ public void testIndirectUpdateStream() throws Exception { new SolrStream( solrUrl + "/" + COLLECTION_Y, // NOTE: Y route params("qt", "/stream", "expr", expr)); - solrStream.setCredentials(WRITE_X_USER, WRITE_X_USER); + solrStream.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(solrStream); assertEquals(1, tuples.size()); assertEquals(10L, tuples.get(0).get("batchIndexed")); @@ -410,7 +420,7 @@ public void testIndirectUpdateStream() throws Exception { new SolrStream( solrUrl + "/" + COLLECTION_X, // NOTE: X route params("qt", "/stream", "expr", expr)); - solrStream.setCredentials(WRITE_X_USER, WRITE_X_USER); + solrStream.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(solrStream); assertEquals(3, tuples.size()); @@ -439,7 +449,7 @@ public void testIndirectUpdateStreamInsufficientCredentials() throws Exception { "/stream", "expr", "update(" + COLLECTION_X + ",batchSize=1," + "tuple(id=42,a_i=1,b_i=5))")); - solrStream.setCredentials(WRITE_Y_USER, WRITE_Y_USER); + solrStream.setCredentials(WRITE_Y_USER, passwordFor(WRITE_Y_USER)); // NOTE: Can't make any assertions about Exception: SOLR-14226 expectThrows( @@ -459,7 +469,7 @@ public void testExecutorUpdateStream() throws Exception { + ", batchSize=5, tuple(id=42,a_i=1,b_i=5))\"))"; final SolrStream solrStream = new SolrStream(solrUrl + "/" + COLLECTION_X, params("qt", "/stream", "expr", expr)); - solrStream.setCredentials(WRITE_X_USER, WRITE_X_USER); + solrStream.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(solrStream); assertEquals(0, tuples.size()); @@ -486,7 +496,7 @@ public void testExecutorUpdateStreamInsufficientCredentials() throws Exception { new SolrStream( solrUrl + "/" + path, params("qt", "/stream", "_trace", "executor_via_" + trace, "expr", expr)); - solrStream.setCredentials(user, user); + solrStream.setCredentials(user, passwordFor(user)); // NOTE: Because of the background threads, no failures will to be returned to client... final List tuples = getTuples(solrStream); @@ -518,7 +528,7 @@ public void testDaemonUpdateStream() throws Exception { + ",tuple(id=42,a_i=1,b_i=5)))"; final SolrStream solrStream = new SolrStream(daemonUrl, params("qt", "/stream", "expr", expr)); - solrStream.setCredentials(WRITE_X_USER, WRITE_X_USER); + solrStream.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(solrStream); assertEquals(1, tuples.size()); // daemon starting status } @@ -533,7 +543,7 @@ public void testDaemonUpdateStream() throws Exception { params( "qt", "/stream", "action", "list")); - daemonCheck.setCredentials(WRITE_X_USER, WRITE_X_USER); + daemonCheck.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(daemonCheck); assertEquals(1, tuples.size()); // our daemon; iterations = tuples.get(0).getLong("iterations"); @@ -555,7 +565,7 @@ public void testDaemonUpdateStream() throws Exception { "qt", "/stream", "action", "kill", "id", "daemonId")); - daemonKiller.setCredentials(WRITE_X_USER, WRITE_X_USER); + daemonKiller.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(daemonKiller); assertEquals(1, tuples.size()); // daemon death status } @@ -582,7 +592,7 @@ public void testDaemonUpdateStreamInsufficientCredentials() throws Exception { final SolrStream solrStream = new SolrStream( daemonUrl, params("qt", "/stream", "_trace", "start_" + daemonId, "expr", expr)); - solrStream.setCredentials(user, user); + solrStream.setCredentials(user, passwordFor(user)); final List tuples = getTuples(solrStream); assertEquals(1, tuples.size()); // daemon starting status } @@ -598,7 +608,7 @@ public void testDaemonUpdateStreamInsufficientCredentials() throws Exception { "qt", "/stream", "_trace", "check_" + daemonId, "action", "list")); - daemonCheck.setCredentials(user, user); + daemonCheck.setCredentials(user, passwordFor(user)); final List tuples = getTuples(daemonCheck); assertEquals(1, tuples.size()); // our daemon; if (log.isInfoEnabled()) { @@ -631,7 +641,7 @@ public void testDaemonUpdateStreamInsufficientCredentials() throws Exception { "kill", "id", daemonId)); - daemonKiller.setCredentials(user, user); + daemonKiller.setCredentials(user, passwordFor(user)); final List tuples = getTuples(daemonKiller); assertEquals(1, tuples.size()); // daemon death status } @@ -663,7 +673,7 @@ public void testSimpleDeleteStream() throws Exception { "/stream", "expr", "delete(" + COLLECTION_X + ",batchSize=1," + "tuple(id=42))")); - solrStream.setCredentials(WRITE_X_USER, WRITE_X_USER); + solrStream.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(solrStream); assertEquals(1, tuples.size()); assertEquals(1L, tuples.get(0).get("totalIndexed")); @@ -697,7 +707,7 @@ public void testSimpleDeleteStreamByQuery() throws Exception { final SolrStream solrStream = new SolrStream(solrUrl + "/" + COLLECTION_X, params("qt", "/stream", "expr", expr)); - solrStream.setCredentials(WRITE_X_USER, WRITE_X_USER); + solrStream.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(solrStream); assertEquals(2, tuples.size()); assertEquals(5L, tuples.get(0).get("totalIndexed")); @@ -758,7 +768,7 @@ public void testSimpleDeleteStreamInsufficientCredentials() throws Exception { "expr", "update(" + COLLECTION_X + ",batchSize=1," + "tuple(id=42))")); - solrStream.setCredentials(user, user); + solrStream.setCredentials(user, passwordFor(user)); // NOTE: Can't make any assertions about Exception: SOLR-14226 expectThrows( @@ -801,7 +811,7 @@ public void testIndirectDeleteStream() throws Exception { "/stream", "expr", "delete(" + COLLECTION_X + ",batchSize=1," + "tuple(id=42z))")); - solrStream.setCredentials(WRITE_X_USER, WRITE_X_USER); + solrStream.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(solrStream); assertEquals(1, tuples.size()); assertEquals(1L, tuples.get(0).get("totalIndexed")); @@ -825,7 +835,7 @@ public void testIndirectDeleteStream() throws Exception { new SolrStream( solrUrl + "/" + COLLECTION_Y, // NOTE: Y route params("qt", "/stream", "expr", expr)); - solrStream.setCredentials(WRITE_X_USER, WRITE_X_USER); + solrStream.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(solrStream); assertEquals(1, tuples.size()); assertEquals(10L, tuples.get(0).get("batchIndexed")); @@ -849,7 +859,7 @@ public void testIndirectDeleteStream() throws Exception { new SolrStream( solrUrl + "/" + COLLECTION_X, // NOTE: X route params("qt", "/stream", "expr", expr)); - solrStream.setCredentials(WRITE_X_USER, WRITE_X_USER); + solrStream.setCredentials(WRITE_X_USER, passwordFor(WRITE_X_USER)); final List tuples = getTuples(solrStream); assertEquals(3, tuples.size()); @@ -888,7 +898,7 @@ public void testIndirectDeleteStreamInsufficientCredentials() throws Exception { "/stream", "expr", "delete(" + COLLECTION_X + ",batchSize=1," + "tuple(id=42))")); - solrStream.setCredentials(WRITE_Y_USER, WRITE_Y_USER); + solrStream.setCredentials(WRITE_Y_USER, passwordFor(WRITE_Y_USER)); // NOTE: Can't make any assertions about Exception: SOLR-14226 expectThrows( diff --git a/solr/webapp/web/js/angular/app.js b/solr/webapp/web/js/angular/app.js index abdd53f0c59c..e4be0017384e 100644 --- a/solr/webapp/web/js/angular/app.js +++ b/solr/webapp/web/js/angular/app.js @@ -379,6 +379,10 @@ solrAdminApp.config([ if ($rootScope.exceptions[config.url]) { delete $rootScope.exceptions[config.url]; } + if (config.url && config.url.startsWith("/api/cluster/security/")) { + // Clear any stale security panel error when a new security request starts + $rootScope.$broadcast('securityApiClearError'); + } activeRequests++; if (sessionStorage.getItem("auth.header")) { config.headers['Authorization'] = sessionStorage.getItem("auth.header"); @@ -420,8 +424,9 @@ solrAdminApp.config([ return rejection; } - // Schema Designer handles errors internally to provide a better user experience than the global error handler + // Schema Designer and Security panels handle errors internally to provide a better user experience than the global error handler var isHandledBySchemaDesigner = rejection.config.url && rejection.config.url.startsWith("/api/schema-designer/"); + var isHandledBySecurity = rejection.config.url && rejection.config.url.startsWith("/api/cluster/security/"); if (rejection.status === 0) { $rootScope.$broadcast('connectionStatusActive'); if (!$rootScope.retryCount) $rootScope.retryCount=0; @@ -450,9 +455,12 @@ solrAdminApp.config([ sessionStorage.setItem("auth.location", $location.path()); $location.path('/login'); } - } else if (rejection.status === 403 && !isHandledBySchemaDesigner) { + } else if (rejection.status === 403 && !isHandledBySchemaDesigner && !isHandledBySecurity) { // No permission $rootScope.showAuthzFailures = true; + } else if (isHandledBySecurity) { + // Let the security panel surface the detailed error in its own dialog + $rootScope.$broadcast('securityApiError', rejection); } else { // schema designer prefers to handle errors itself if (!isHandledBySchemaDesigner) { @@ -464,10 +472,12 @@ solrAdminApp.config([ return {request: started, response: ended, responseError: failed}; }) -.config(function($httpProvider) { +.config(function($httpProvider, $qProvider) { $httpProvider.interceptors.push("httpInterceptor"); // Force BasicAuth plugin to serve us a 'Authorization: xBasic xxxx' header so browser will not pop up login dialogue $httpProvider.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; + // Suppress AngularJS 1.6+ "Possibly unhandled rejection" console noise; errors are handled via callbacks and the security/schema-designer error dialogs + $qProvider.errorOnUnhandledRejections(false); }) .directive('fileModel', function ($parse) { return { diff --git a/solr/webapp/web/js/angular/controllers/security.js b/solr/webapp/web/js/angular/controllers/security.js index fd65a289988f..776116eedd52 100644 --- a/solr/webapp/web/js/angular/controllers/security.js +++ b/solr/webapp/web/js/angular/controllers/security.js @@ -160,14 +160,31 @@ solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cooki $scope.errorHandler = function (e) { var error = e.data && e.data.error ? e.data.error : null; if (error && error.msg) { - $scope.securityAPIError = error.msg; - $scope.securityAPIErrorDetails = e.data.errorDetails; + var allMessages = []; + if (error.details && Array.isArray(error.details)) { + error.details.forEach(function(detail) { + if (detail.errorMessages && Array.isArray(detail.errorMessages)) { + allMessages = allMessages.concat(detail.errorMessages); + } + }); + } + $scope.securityAPIError = allMessages.length > 0 ? allMessages[0] : error.msg; + $scope.securityAPIErrorDetails = allMessages.length > 1 ? allMessages.slice(1).join("\n") : null; } else if (e.data && e.data.message) { $scope.securityAPIError = e.data.message; - $scope.securityAPIErrorDetails = JSON.stringify(e.data); + $scope.securityAPIErrorDetails = null; } }; + // Security API command errors (HTTP 4xx/5xx) are broadcast by the global httpInterceptor; + // show them in this panel's error dialog rather than the generic page-header banner. + $scope.$on('securityApiError', function (event, rejection) { + $scope.errorHandler(rejection); + }); + $scope.$on('securityApiClearError', function () { + $scope.closeErrorDialog(); + }); + $scope.showHelp = function (id) { if ($scope.helpId && ($scope.helpId === id || id === '')) { delete $scope.helpId;