Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ compatible with the existing authentication protocol and the chart's documented
password constraints. When the signing-key environment variable is omitted,
the image reuses the configured key instead of rotating it.

Instead of `auth.admin_pa`, a REST configuration you mount yourself can set
`auth.admin_pa_file` to the path of a mounted secret. The Server reads that file as
raw UTF-8 and drops a leading BOM and one trailing line break, so the password
needs no properties escaping. The path must lead to a regular file, though
symlinks such as the ones Kubernetes uses for mounted Secrets are followed. The
password may be at most 72 bytes in UTF-8, because the password hash ignores
anything after that. When set, it overrides `auth.admin_pa` and the
init-store password prompt. With PD (hstore), including when init-store is
disabled, the file is read on every start, so keep the secret mounted for as long
as the option is set. With init-store disabled, startup also requires this option
or an explicit non-empty `auth.admin_pa`, so do not drop both.

Older images could change signing keys containing spaces or Unicode, and a
trailing space could hide the following configuration property. On upgrade,
explicitly provide the intended key for those deployments. Tokens signed with
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ private static void registerPrivateActions() {
// HugeGraph
Reflection.registerFieldsToFilter(StandardAuthenticator.class, "graph");
Reflection.registerMethodsToFilter(StandardAuthenticator.class, "initAdminUser",
"inputPassword", "graph");
"inputPassword", "graph", "initialAdminPassword",
"readAdminPasswordFile");
Reflection.registerFieldsToFilter(HugeFactoryAuthProxy.class, "PROTECT_METHODS");
Reflection.registerMethodsToFilter(HugeFactoryAuthProxy.class, "genRegisterPrivateActions",
"registerClass", "registerPrivateActions",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,26 @@
package org.apache.hugegraph.auth;

import java.io.Console;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Scanner;

import org.apache.commons.lang.StringUtils;
import org.apache.hugegraph.HugeGraph;
import org.apache.hugegraph.config.ConfigException;
import org.apache.hugegraph.config.CoreOptions;
import org.apache.hugegraph.config.HugeConfig;
import org.apache.hugegraph.config.ServerOptions;
Expand All @@ -43,12 +54,28 @@
public class StandardAuthenticator implements HugeAuthenticator {

private static final String INITING_STORE = "initing_store";
private static final int MAX_ADMIN_PA_FILE_BYTES = 4096;
Comment thread
arshilkxwork marked this conversation as resolved.
private static final int MAX_ADMIN_PASSWORD_BYTES = 72;

private HugeGraph graph = null;

private void initAdminUser() throws Exception {
if (this.requireInitAdminUser()) {
this.initAdminUser(this.inputPassword());
private void initAdminUser(HugeConfig config) throws Exception {
// init-store only closes the graphs it initialized and skips hstore
// ones, so close the auth graph here even when the password file is
// refused, without letting a close failure hide that error
try {
if (this.requireInitAdminUser()) {
String file = config.get(ServerOptions.ADMIN_PA_FILE);
this.initAdminUser(file.isEmpty() ? this.inputPassword() :
readAdminPasswordFile(file));
}
} catch (Throwable e) {
try {
this.graph.close();
} catch (Throwable closeError) {
e.addSuppressed(closeError);
}
throw e;
}
this.graph.close();
}
Expand Down Expand Up @@ -202,8 +229,86 @@ public static void initAdminUserIfNeeded(String confFile) throws Exception {
config.addProperty(INITING_STORE, true);
auth.setup(config);
if (auth.graph().backendStoreFeatures().supportsPersistence()) {
auth.initAdminUser();
auth.initAdminUser(config);
}
}

/**
* The initial admin password for the bootstrap paths that cannot prompt.
* auth.admin_pa goes through the properties grammar, which trims it,
* processes backslash escapes and decodes it as ISO-8859-1, so
* auth.admin_pa_file takes precedence when both are set.
*/
public static String initialAdminPassword(HugeConfig config) {
String file = config.get(ServerOptions.ADMIN_PA_FILE);
if (!file.isEmpty()) {
return readAdminPasswordFile(file);
}
return config.get(ServerOptions.ADMIN_PA);
}

private static String readAdminPasswordFile(String file) {
String option = ServerOptions.ADMIN_PA_FILE.name();
byte[] bytes;
try {
Path path = Paths.get(file);
// Checked before opening, because opening a FIFO blocks until a
// writer connects. Symlinks are followed, so a Kubernetes secret
// mounted through ..data links still counts as a regular file
if (!Files.readAttributes(path, BasicFileAttributes.class)
.isRegularFile()) {
throw new ConfigException("The file '%s' set by '%s' is not a regular file",
file, option);
}
// Bounded, so a path that points at a large file fails quickly
try (InputStream in = Files.newInputStream(path)) {
bytes = in.readNBytes(MAX_ADMIN_PA_FILE_BYTES + 1);
}
} catch (IOException | InvalidPathException e) {
throw new ConfigException("Failed to read the file '%s' set by '%s' (%s)",
e, file, option, e.getClass().getSimpleName());
}
if (bytes.length > MAX_ADMIN_PA_FILE_BYTES) {
throw new ConfigException("The file '%s' set by '%s' is larger than %s bytes",
file, option, MAX_ADMIN_PA_FILE_BYTES);
}

String password;
try {
password = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
.toString();
} catch (CharacterCodingException e) {
throw new ConfigException("The file '%s' set by '%s' is not valid UTF-8",
e, file, option);
}

// Some Windows editors and PowerShell prepend a BOM; it is never meant
// as part of the password and would make the admin unreachable
if (password.startsWith("\uFEFF")) {
password = password.substring(1);
}
// Drop the line break that echo, editors and most secret tools append
if (password.endsWith("\r\n")) {
password = password.substring(0, password.length() - 2);
} else if (password.endsWith("\n")) {
password = password.substring(0, password.length() - 1);
}
if (password.isEmpty()) {
throw new ConfigException("The file '%s' set by '%s' is empty",
file, option);
}
// jBCrypt hashes only the first 72 bytes, so a longer password would
// also accept anything that shares its first 72 bytes
if (password.getBytes(StandardCharsets.UTF_8).length > MAX_ADMIN_PASSWORD_BYTES) {
throw new ConfigException("The password in the file '%s' set by '%s' is " +
"longer than %s bytes in UTF-8, the most that " +
"the password hash uses",
file, option, MAX_ADMIN_PASSWORD_BYTES);
}
return password;
}

private class TokenSaslAuthenticator implements SaslNegotiator {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -480,11 +480,11 @@ public class ServerOptions extends OptionHolder {
""
);

// TODO: accept the initial admin password from the environment (or document the properties
// contract): read through PropertiesConfiguration, the value is trimmed, backslash-unescaped
// and decoded as ISO-8859-1, so the stored password can differ from what the operator set.
// The Helm chart (helm/hugegraph) refuses padded, backslash or non-ASCII admin passwords in
// its schema and Server wrapper; relax that guard once the credential bypasses the file.
// Read through PropertiesConfiguration: the value is trimmed, backslash-unescaped and decoded
// as ISO-8859-1, so leading or trailing spaces, backslashes and characters outside printable
// ASCII need escaping in the file. ADMIN_PA_FILE skips that grammar. The Helm chart
// (helm/hugegraph) still refuses padded, backslash or non-ASCII admin passwords; relax that
// guard once it passes ADMIN_PA_FILE.
public static final ConfigOption<String> ADMIN_PA =
new ConfigOption<>(
"auth.admin_pa",
Expand All @@ -494,6 +494,22 @@ public class ServerOptions extends OptionHolder {
"pa"
);

public static final ConfigOption<String> ADMIN_PA_FILE =
new ConfigOption<>(
"auth.admin_pa_file",
"The path of a regular file (symlinks are followed) " +
"holding the default password for built-in admin " +
"account, read as raw UTF-8 with a leading BOM and one " +
"trailing line break removed. The password may be at " +
"most 72 bytes in UTF-8, the most the hash uses. When " +
"set, it takes precedence over auth.admin_pa and the " +
"init-store prompt; takes effect on first startup. PD " +
"and non-persistent backends read it on every start, so " +
"keep it readable while this option is set.",
null,
""
);

public static final ConfigOption<String> SSL_KEYSTORE_FILE =
new ConfigOption<>(
"ssl.keystore_file",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,8 @@ private void loadMetaFromPD() {
this.initK8sManagerIfNeeded(conf);
if (shouldBootstrapAdmin(this.authenticator,
conf.get(ServerOptions.AUTH_REMOTE_URL))) {
this.initAdminUserIfNeeded(conf.get(ServerOptions.ADMIN_PA));
this.initAdminUserIfNeeded(
StandardAuthenticator.initialAdminPassword(conf));
Comment on lines +377 to +378

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: no test runs this call with auth.admin_pa_file set, and it is the only link between the option and the PD bootstrap, the path it mostly serves (hstore with a mounted Secret). The review of 128c0b3 noted the gap; it still holds at 7ba00bd. loadMetaFromPD() runs only from the constructor when usePD=true, no unit test builds a GraphManager that way, and the hstore API job starts the Server with the option unset.

Evidence (Linux x86_64, OpenJDK 17.0.20.1): with these two lines put back to this.initAdminUserIfNeeded(conf.get(ServerOptions.ADMIN_PA));, AdminPasswordFileTest, GraphManagerAdminInitTest and InitStoreConfigTest still pass all 42 of their tests.

Requested change: move the shouldBootstrapAdmin guard and this call into a small method that takes the config, and cover it in GraphManagerAdminInitTest, which already swaps in an AuthMetaManager over a mock driver: one case with a password file whose content StringEncoding.checkPassword accepts against the stored admin hash, and one with auth.remote_url set and a missing file, which must not be read.

}

this.createDefaultGraphSpaceIfNeeded(conf);
Expand All @@ -394,6 +395,18 @@ private static boolean shouldBootstrapAdmin(HugeAuthenticator authenticator,
remoteUrl.isEmpty();
}

/**
* With auth.remote_url the built-in authenticator switches to a remote
* AuthManager and never creates a local admin, so the admin password, and
* a password file that may not even be mounted, must not be resolved.
* Custom authenticators keep receiving initAdminUser() as before.
*/
private static boolean usesRemoteAuth(HugeAuthenticator authenticator,
String remoteUrl) {
return authenticator instanceof StandardAuthenticator &&
!remoteUrl.isEmpty();
}

/**
* Creates the built-in admin account in PD metadata. With init-store
* disabled this is the only bootstrap that admin gets, and init-store's
Expand Down Expand Up @@ -1876,14 +1889,18 @@ private void checkBackendVersionOrExit(HugeConfig config) {
assert hugegraph != null;
if (!hugegraph.backendStoreFeatures().supportsPersistence()) {
hugegraph.initBackend();
if (this.requireAuthentication()) {
String adminPassword = config.get(ServerOptions.ADMIN_PA);
if (this.requireAuthentication() &&
!usesRemoteAuth(this.authenticator,
config.get(ServerOptions.AUTH_REMOTE_URL))) {
// Outside the try so a bad password file names itself
String adminPassword =
StandardAuthenticator.initialAdminPassword(config);
Comment thread
arshilkxwork marked this conversation as resolved.
try {
this.authenticator().initAdminUser(adminPassword);
} catch (Exception e) {
throw new BackendException(
"The backend store of '%s' can't " +
"initialize admin user", hugegraph.spaceGraphName());
"initialize admin user", e, hugegraph.spaceGraphName());
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ arthas.disabled_commands=jad
#auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator
# for admin password, By default, it is pa and takes effect upon the first startup
#auth.admin_pa=pa
# or read it from a file as raw UTF-8 (a leading BOM and one trailing line break are dropped), so
# spaces, backslashes and non-ASCII characters need no escaping; overrides auth.admin_pa.
# It must be a regular file (symlinks are followed) holding at most 72 UTF-8 bytes.
# PD and non-persistent backends read it on every start, so keep it readable while set
#auth.admin_pa_file=/run/secrets/hugegraph-admin-password
#auth.graph_store=hugegraph

# use pd
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,13 +223,22 @@ private static void checkAdminBootstrapReachable(HugeConfig conf,
"', not 'hstore'");
}

// A password file is read now, so an unreadable or empty one fails
// here instead of after the server has started
if (!conf.get(ServerOptions.ADMIN_PA_FILE).isEmpty()) {
StandardAuthenticator.initialAdminPassword(conf);
return;
}

// The server creates the admin from this value and cannot prompt for
// it, and Docker PASSWORD never reaches this path. An absent or empty
// one would hand out the public 'pa' default, so fail instead. Checked
// with containsKey because the default is not a configured secret.
if (!conf.containsKey(ServerOptions.ADMIN_PA.name()) ||
conf.get(ServerOptions.ADMIN_PA).isEmpty()) {
throw unreachableAdmin(restConf, "no explicit non-empty '" +
throw unreachableAdmin(restConf, "neither '" +
ServerOptions.ADMIN_PA_FILE.name() +
"' nor an explicit non-empty '" +
ServerOptions.ADMIN_PA.name() +
"' is configured, so the admin " +
"would be created with the " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
import org.apache.hugegraph.unit.api.gremlin.GremlinQueryAPITest;
import org.apache.hugegraph.unit.api.space.GraphSpaceAPITest;
import org.apache.hugegraph.unit.api.space.SchemaTemplateAPITest;
import org.apache.hugegraph.unit.auth.AdminPasswordFileTest;
import org.apache.hugegraph.unit.auth.HugeGraphAuthProxyTest;
import org.apache.hugegraph.unit.cache.CacheManagerTest;
import org.apache.hugegraph.unit.cache.CacheTest;
Expand Down Expand Up @@ -227,6 +228,7 @@
ServerInfoManagerTest.class,
TaskSchedulerServerInfoTest.class,
HugeGraphAuthProxyTest.class,
AdminPasswordFileTest.class,
SchemaElementTest.class,
HugeGraphTestInfrastructureTest.class,
HugeElementViewTest.class,
Expand Down
Loading
Loading