Skip to content

Commit

Permalink
feat: Add authenticationPluginClassName option to provide passwords a…
Browse files Browse the repository at this point in the history
…t runtime

Adds authenticationPluginClassName connection property that allows end users to specify a class
that will provide the connection passwords at runtime. This allows configuring a connection with
a password that must be generated on the fly or periodically changes.

Custom implementations have access to the full connection properties and the type of authentication
that is being requested by the server, e.g. MD5 or SCRAM.
  • Loading branch information
sehrope committed Dec 28, 2021
1 parent e5fb0ea commit 473091a
Show file tree
Hide file tree
Showing 10 changed files with 258 additions and 19 deletions.
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -142,6 +142,7 @@ In addition to the standard connection parameters the driver supports a number o
| adaptiveFetchMaximum | Integer | -1 | Specifies maximum number of rows, which can be calculated by adaptiveFetch. Number of rows used by adaptiveFetch cannot go above this value. Any negative number set as adaptiveFetchMaximum is used by adaptiveFetch as infinity number of rows.
| localSocketAddress | String | null | Hostname or IP address given to explicitly configure the interface that the driver will bind the client side of the TCP/IP connection to when connecting.
| quoteReturningIdentifiers | Boolean | true | By default we double quote returning identifiers. Some ORM's already quote them. Switch allows them to turn this off
| authenticationPluginClassName | String | null | Fully qualified class name of the class implementing the AuthenticationPlugin interface. If this is null, the password value in the connection properties will be used.

## Contributing
For information on how to contribute to the project see the [Contributing Guidelines](CONTRIBUTING.md)
Expand Down
5 changes: 5 additions & 0 deletions docs/documentation/head/connect.md
Expand Up @@ -586,6 +586,11 @@ Connection conn = DriverManager.getConnection(url);
If we quote them, then we end up sending ""colname"" to the backend instead of "colname"
which will not be found.

* **authenticationPluginClassName** == String

Fully qualified class name of the class implementing the AuthenticationPlugin interface.
If this is null, the password value in the connection properties will be used.

<a name="unix sockets"></a>
## Unix sockets

Expand Down
10 changes: 10 additions & 0 deletions pgjdbc/src/main/java/org/postgresql/PGProperty.java
Expand Up @@ -82,6 +82,16 @@ public enum PGProperty {
null,
"Assume the server is at least that version"),

/**
* AuthenticationPluginClass
*/

AUTHENTICATION_PLUGIN_CLASS_NAME(
"authenticationPluginClassName",
null,
"Name of class which implements AuthenticationPlugin"
),

/**
* Specifies what the driver should do if a query fails. In {@code autosave=always} mode, JDBC driver sets a savepoint before each query,
* and rolls back to that savepoint in case of failure. In {@code autosave=never} mode (default), no savepoint dance is made ever.
Expand Down
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2021, PostgreSQL Global Development Group
* See the LICENSE file in the project root for more information.
*/

package org.postgresql.core;

import org.postgresql.PGProperty;
import org.postgresql.plugin.AuthenticationPlugin;
import org.postgresql.plugin.AuthenticationRequestType;
import org.postgresql.util.GT;
import org.postgresql.util.ObjectFactory;
import org.postgresql.util.PSQLException;
import org.postgresql.util.PSQLState;

import org.checkerframework.checker.nullness.qual.Nullable;

import java.nio.charset.StandardCharsets;
import java.util.Properties;
import java.util.logging.Level;
import java.util.logging.Logger;

public class AuthenticationPluginManager {
private static final Logger LOGGER = Logger.getLogger(AuthenticationPluginManager.class.getName());

private AuthenticationPluginManager() {
}

/**
* If a password is requested by the server during connection initiation, this
* method will be invoked to supply the password. This method will only be
* invoked if the server actually requests a password, e.g. trust authentication
* will skip it entirely.
*
* @param type The authentication type that is being requested
* @param info The connection properties for the connection
* @return The password to use for authentication or null if none is available
* @throws PSQLException Throws a PSQLException if the plugin class cannot be instantiated
*/
public static @Nullable String getPassword(AuthenticationRequestType type, Properties info) throws PSQLException {
String authPluginClassName = PGProperty.AUTHENTICATION_PLUGIN_CLASS_NAME.get(info);

if (authPluginClassName == null || authPluginClassName.equals("")) {
// Default auth plugin simply pulls password directly from connection properties
return PGProperty.PASSWORD.get(info);
}

AuthenticationPlugin authPlugin;
try {
authPlugin = (AuthenticationPlugin) ObjectFactory.instantiate(authPluginClassName, info,
false, null);
} catch (Exception ex) {
LOGGER.log(Level.FINE, "Unable to load Authentication Plugin " + ex.toString());
throw new PSQLException(ex.getMessage(), PSQLState.UNEXPECTED_ERROR);
}
return authPlugin.getPassword(type);
}

/**
* Helper that wraps getPassword(...), checks that it is not-null, and encodes
* it as a byte array. Used by internal code paths that require an encoded password that may be an
* empty string, but not null.
*
* @param type The authentication type that is being requested
* @param info The connection properties for the connection
* @return The password to use for authentication encoded as a byte array
* @throws PSQLException Throws a PSQLException if the plugin class cannot be instantiated or if the retrieved password is null.
*/
public static byte[] getEncodedPassword(AuthenticationRequestType type, Properties info) throws PSQLException {
String password = getPassword(type, info);

if (password == null) {
throw new PSQLException(
GT.tr("The server requested password-based authentication, but no password was provided."),
PSQLState.CONNECTION_REJECTED);
}

return password.getBytes(StandardCharsets.UTF_8);
}
}
Expand Up @@ -9,6 +9,7 @@
import static org.postgresql.util.internal.Nullness.castNonNull;

import org.postgresql.PGProperty;
import org.postgresql.core.AuthenticationPluginManager;
import org.postgresql.core.ConnectionFactory;
import org.postgresql.core.PGStream;
import org.postgresql.core.QueryExecutor;
Expand All @@ -26,6 +27,7 @@
import org.postgresql.hostchooser.HostStatus;
import org.postgresql.jdbc.GSSEncMode;
import org.postgresql.jdbc.SslMode;
import org.postgresql.plugin.AuthenticationRequestType;
import org.postgresql.sspi.ISSPIClient;
import org.postgresql.util.GT;
import org.postgresql.util.HostSpec;
Expand Down Expand Up @@ -247,6 +249,7 @@ public QueryExecutor openConnectionImpl(HostSpec[] hostSpecs, Properties info) t
} catch (SQLException | IOException ee) {
ex = ee;
}

if (ex != null) {
log(Level.FINE, "sslMode==PREFER, however non-SSL connection failed as well", ex);
// non-SSL failed as well, so re-throw original exception
Expand Down Expand Up @@ -454,7 +457,6 @@ private PGStream enableGSSEncrypted(PGStream pgStream, GSSEncMode gssEncMode, St
}

// attempt to acquire a GSS encrypted connection
String password = PGProperty.PASSWORD.get(info);
LOGGER.log(Level.FINEST, " FE=> GSSENCRequest");

// Send GSSEncryption request packet
Expand Down Expand Up @@ -492,6 +494,7 @@ private PGStream enableGSSEncrypted(PGStream pgStream, GSSEncMode gssEncMode, St
case 'G':
LOGGER.log(Level.FINEST, " <=BE GSSEncryptedOk");
try {
String password = AuthenticationPluginManager.getPassword(AuthenticationRequestType.GSS, info);
org.postgresql.gss.MakeGSS.authenticate(true, pgStream, host, user, password,
PGProperty.JAAS_APPLICATION_NAME.get(info),
PGProperty.KERBEROS_SERVER_NAME.get(info), false, // TODO: fix this
Expand Down Expand Up @@ -613,8 +616,6 @@ private void doAuthentication(PGStream pgStream, String host, String user, Prope
// Now get the response from the backend, either an error message
// or an authentication request

String password = PGProperty.PASSWORD.get(info);

/* SSPI negotiation state, if used */
ISSPIClient sspiClient = null;

Expand Down Expand Up @@ -656,15 +657,9 @@ private void doAuthentication(PGStream pgStream, String host, String user, Prope
LOGGER.log(Level.FINEST, " <=BE AuthenticationReqMD5(salt={0})", Utils.toHexString(md5Salt));
}

if (password == null) {
throw new PSQLException(
GT.tr(
"The server requested password-based authentication, but no password was provided."),
PSQLState.CONNECTION_REJECTED);
}

byte[] encodedPassword = AuthenticationPluginManager.getEncodedPassword(AuthenticationRequestType.MD5_PASSWORD, info);
byte[] digest =
MD5Digest.encode(user.getBytes(StandardCharsets.UTF_8), password.getBytes(StandardCharsets.UTF_8), md5Salt);
MD5Digest.encode(user.getBytes(StandardCharsets.UTF_8), encodedPassword, md5Salt);

if (LOGGER.isLoggable(Level.FINEST)) {
LOGGER.log(Level.FINEST, " FE=> Password(md5digest={0})", new String(digest, StandardCharsets.US_ASCII));
Expand All @@ -683,14 +678,7 @@ private void doAuthentication(PGStream pgStream, String host, String user, Prope
LOGGER.log(Level.FINEST, "<=BE AuthenticationReqPassword");
LOGGER.log(Level.FINEST, " FE=> Password(password=<not shown>)");

if (password == null) {
throw new PSQLException(
GT.tr(
"The server requested password-based authentication, but no password was provided."),
PSQLState.CONNECTION_REJECTED);
}

byte[] encodedPassword = password.getBytes(StandardCharsets.UTF_8);
byte[] encodedPassword = AuthenticationPluginManager.getEncodedPassword(AuthenticationRequestType.CLEARTEXT_PASSWORD, info);

pgStream.sendChar('p');
pgStream.sendInteger4(4 + encodedPassword.length + 1);
Expand Down Expand Up @@ -765,6 +753,7 @@ private void doAuthentication(PGStream pgStream, String host, String user, Prope
castNonNull(sspiClient).startSSPI();
} else {
/* Use JGSS's GSSAPI for this request */
String password = AuthenticationPluginManager.getPassword(AuthenticationRequestType.GSS, info);
org.postgresql.gss.MakeGSS.authenticate(false, pgStream, host, user, password,
PGProperty.JAAS_APPLICATION_NAME.get(info),
PGProperty.KERBEROS_SERVER_NAME.get(info), usespnego,
Expand All @@ -783,6 +772,7 @@ private void doAuthentication(PGStream pgStream, String host, String user, Prope
case AUTH_REQ_SASL:
LOGGER.log(Level.FINEST, " <=BE AuthenticationSASL");

String password = AuthenticationPluginManager.getPassword(AuthenticationRequestType.SASL, info);
if (password == null) {
throw new PSQLException(
GT.tr(
Expand Down
20 changes: 20 additions & 0 deletions pgjdbc/src/main/java/org/postgresql/ds/common/BaseDataSource.java
Expand Up @@ -1321,6 +1321,26 @@ public void setURL(String url) {
setUrl(url);
}

/**
*
* @return the class name to use for the Authentication Plugin.
* This can be null in which case the default password authentication plugin will be used
*/
public @Nullable String getAuthenticationPluginClassName() {
return PGProperty.AUTHENTICATION_PLUGIN_CLASS_NAME.get(properties);
}

/**
*
* @param className name of a class which implements {@link org.postgresql.plugin.AuthenticationPlugin}
* This class will be used to get the encoded bytes to be sent to the server as the
* password to authenticate the user.
*
*/
public void setAuthenticationPluginClassName(String className) {
PGProperty.AUTHENTICATION_PLUGIN_CLASS_NAME.set(properties, className);
}

public @Nullable String getProperty(String name) throws SQLException {
PGProperty pgProperty = PGProperty.forName(name);
if (pgProperty != null) {
Expand Down
@@ -0,0 +1,16 @@
/*
* Copyright (c) 2021, PostgreSQL Global Development Group
* See the LICENSE file in the project root for more information.
*/

package org.postgresql.plugin;

import org.postgresql.util.PSQLException;

import org.checkerframework.checker.nullness.qual.Nullable;

public interface AuthenticationPlugin {
@Nullable
String getPassword(AuthenticationRequestType type) throws PSQLException;

}
@@ -0,0 +1,13 @@
/*
* Copyright (c) 2021, PostgreSQL Global Development Group
* See the LICENSE file in the project root for more information.
*/

package org.postgresql.plugin;

public enum AuthenticationRequestType {
CLEARTEXT_PASSWORD,
GSS,
MD5_PASSWORD,
SASL,
}
@@ -0,0 +1,86 @@
/*
* Copyright (c) 2021, PostgreSQL Global Development Group
* See the LICENSE file in the project root for more information.
*/

package org.postgresql.test.plugin;

import org.postgresql.PGProperty;
import org.postgresql.core.ServerVersion;
import org.postgresql.plugin.AuthenticationPlugin;
import org.postgresql.plugin.AuthenticationRequestType;
import org.postgresql.test.TestUtil;
import org.postgresql.util.PSQLException;

import org.checkerframework.checker.nullness.qual.Nullable;
import org.junit.Assert;
import org.junit.BeforeClass;
import org.junit.Test;

import java.sql.Connection;
import java.sql.SQLException;
import java.util.Properties;
import java.util.function.Consumer;

public class AuthenticationPluginTest {
@BeforeClass
public static void setUp() throws SQLException {
TestUtil.assumeHaveMinimumServerVersion(ServerVersion.v10);
}

public static class DummyAuthenticationPlugin implements AuthenticationPlugin {
private static Consumer<AuthenticationRequestType> onGetPassword;

@Override
public @Nullable String getPassword(AuthenticationRequestType type) throws PSQLException {
onGetPassword.accept(type);

// Ex: "MD5" => "DUMMY-MD5"
return "DUMMY-" + type.toString();
}
}

private void testAuthPlugin(String username, String passwordEncryption, AuthenticationRequestType expectedType) throws SQLException {
createRole(username, passwordEncryption, "DUMMY-" + expectedType.toString());
try {
Properties props = new Properties();
props.setProperty(PGProperty.AUTHENTICATION_PLUGIN_CLASS_NAME.getName(), DummyAuthenticationPlugin.class.getName());
props.setProperty("username", username);

boolean[] wasCalled = { false };
DummyAuthenticationPlugin.onGetPassword = type -> {
wasCalled[0] = true;
Assert.assertEquals("The authentication type should match", expectedType, type);
};
try (Connection conn = TestUtil.openDB(props)) {
Assert.assertTrue("The custom authentication plugin should be invoked", wasCalled[0]);
}
} finally {
dropRole(username);
}
}

@Test
public void testAuthPluginMD5() throws Exception {
testAuthPlugin("auth_plugin_test_md5", "md5", AuthenticationRequestType.MD5_PASSWORD);
}

@Test
public void testAuthPluginSASL() throws Exception {
testAuthPlugin("auth_plugin_test_sasl", "scram-sha-256", AuthenticationRequestType.SASL);
}

private static void createRole(String username, String passwordEncryption, String password) throws SQLException {
try (Connection conn = TestUtil.openPrivilegedDB()) {
TestUtil.execute("SET password_encryption='" + passwordEncryption + "'", conn);
TestUtil.execute("DROP ROLE IF EXISTS " + username, conn);
TestUtil.execute("CREATE USER " + username + " WITH PASSWORD '" + password + "'", conn);
}
}

private static void dropRole(String username) throws SQLException {
try (Connection conn = TestUtil.openPrivilegedDB()) {
TestUtil.execute("DROP ROLE IF EXISTS " + username, conn);
}
}
}
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2021, PostgreSQL Global Development Group
* See the LICENSE file in the project root for more information.
*/

package org.postgresql.test.plugin;

import org.junit.runner.RunWith;
import org.junit.runners.Suite;
import org.junit.runners.Suite.SuiteClasses;

@RunWith(Suite.class)
@SuiteClasses({
AuthenticationPluginTest.class,
})
public class PluginTestSuite {

}

0 comments on commit 473091a

Please sign in to comment.