Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce a plugin manager interface to allow different mechanisms to acquire a password. #2321

Closed
wants to merge 9 commits into from
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -139,6 +139,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 default PasswordAuthentication plugin will be used

## Contributing
For information on how to contribute to the project see the [Contributing Guidelines](CONTRIBUTING.md)
Expand Down
197 changes: 101 additions & 96 deletions docs/documentation/head/connect.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions pgjdbc/src/main/java/org/postgresql/Driver.java
Expand Up @@ -463,7 +463,7 @@ public Connection getResult(long timeout) throws SQLException {
* @throws SQLException if the connection could not be made
*/
private static Connection makeConnection(String url, Properties props) throws SQLException {
return new PgConnection(hostSpecs(props), user(props), database(props), props, url);
return new PgConnection(hostSpecs(props), props, url);
}

/**
Expand Down Expand Up @@ -657,8 +657,8 @@ private static HostSpec[] hostSpecs(Properties props) {
/**
* @return the username of the URL
*/
private static String user(Properties props) {
return props.getProperty("user", "");
private static @Nullable String user(Properties props) {
return PGProperty.USER.get(props);
}

/**
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
16 changes: 16 additions & 0 deletions pgjdbc/src/main/java/org/postgresql/core/AuthenticationPlugin.java
@@ -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.core;

import org.postgresql.util.PSQLException;

import java.util.Properties;

public interface AuthenticationPlugin {

byte[] getEncodedPassword(Properties info) throws PSQLException, PSQLException;
Copy link
Member

Choose a reason for hiding this comment

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

Is there risk in providing (i.e. leaking) reference to the mutable Properties instance?

Copy link
Member

Choose a reason for hiding this comment

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

Maybe Function<PGProperty, String>

Copy link
Member

Choose a reason for hiding this comment

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

It'd be nice if it's read-only but I don't think it's an issue. We already do it like this for anything created by ObjectFactory:

public static Object instantiate(String classname, Properties info, boolean tryString,
@Nullable String stringarg)
throws ClassNotFoundException, SecurityException, NoSuchMethodException,
IllegalArgumentException, InstantiationException, IllegalAccessException,
InvocationTargetException {
@Nullable Object[] args = {info};
Constructor<?> ctor = null;
Class<?> cls = Class.forName(classname);
try {
ctor = cls.getConstructor(Properties.class);
} catch (NoSuchMethodException ignored) {
}
if (tryString && ctor == null) {
try {
ctor = cls.getConstructor(String.class);
args = new String[]{stringarg};
} catch (NoSuchMethodException ignored) {
}
}
if (ctor == null) {
ctor = cls.getConstructor();
args = new Object[0];
}
return ctor.newInstance(args);

Plus we're blindly running whatever user code they supply to us via the class so in theory they could do whatever they want anyway.

Copy link
Member

Choose a reason for hiding this comment

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

I was not tracking on that. Do we have it documented anywhere that mutating the Properties instance could lead to indeterminate behavior (presumably not just for this connection but other connections, including future)?

Copy link
Member

Choose a reason for hiding this comment

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

Yes, I'm not sure it's listed anywhere but it should be.

Modifying one should not impact any other connections though. Each one has their own copy even if it starts from the same common Properties conf:

Properties props = new Properties(defaults);

Copy link
Contributor

Choose a reason for hiding this comment

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

IMO the method name getEncodedPassword() should be encodedPassword() in that this really isn't a "getter" per say.


}
Copy link

Choose a reason for hiding this comment

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

I would prefer somewhat more generic interface that would include different auth-methods that PG supports today and some future looking like oauth2 access token that are currently commonly implemented through overloaded password field (though they are nothing like a passwords), and in future would hopefully get it's own auth-method (but keep the existing plugin ;) )

public interface AuthenticationPlugin {
  AuthenticationInfo getAuthenticationInfo(Properties info) throws PSQLException, PSQLException;
}

public class AuthenticationInfo {
  final String authenticationType;
  final byte[] credential;

   AuthenticationInfo (String authenticationType, byte[] credential) {
        this.authenticationType= authenticationType;
        this.credential= credential;
    }
}

Copy link
Member Author

Choose a reason for hiding this comment

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

Why would you need to know the auth method? I would think at the end of the day all we need is the password?
How would I use the AuthenticationInfo ?

Copy link

Choose a reason for hiding this comment

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

If you scope AuthenticationPlugin only to Password auth-method then you would not need that info (and you may call plugin PasswordAuthenticationPlugin).

My proposal is to make plugin applicable to the other auth-methods as well. Depending on authenticationType the driver would put credential (password) in use differently.

That said, below may be a better interface.

public interface AuthenticationPlugin {
    String getAuthenticationType(Properties info);
    byte[] getCredential(Properties info) throws PSQLException, PSQLException;
}

Copy link
Member

Choose a reason for hiding this comment

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

What would getAuthenticationType(...) return? Is that supposed to be the server auth method? If so, that would not work as the server decides how it wants the client to authenticate.

Copy link
Member Author

Choose a reason for hiding this comment

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

The intended use is that if you want a different authentication mechanism you load it via Class.forName() and acquire your password any way you wish. Ultimately the server only needs the password bytes. As @sehrope mentioned there is no real way to determine many of the auth methods ahead of time.

Copy link

Choose a reason for hiding this comment

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

In case server determines the method should we have something like this?

public interface AuthenticationPlugin {
    boolean isSupported(String authenticationType, Properties info);
    byte[] getCredential(String authenticationType, Properties info) throws PSQLException, PSQLException;
}

Copy link
Member Author

Choose a reason for hiding this comment

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

There is currently one place where the server determines that we would use GSS encryption, but not authentication. I don't see anywhere else in the protocol where this happens.

@@ -0,0 +1,32 @@
/*
* 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 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());

public static AuthenticationPlugin getAuthenticationPlugin(Properties info) throws Exception {
String authenticationClassName = PGProperty.AUTHENTICATION_PLUGIN_CLASS_NAME.getSetString(info);

if ( authenticationClassName == null ) {
return new PasswordAuthentication();
} else {
davecramer marked this conversation as resolved.
Show resolved Hide resolved
try {
return (AuthenticationPlugin) Class.forName(authenticationClassName).getDeclaredConstructor().newInstance();
Copy link
Member

Choose a reason for hiding this comment

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

does there need to be consideration about what classloader to use?

Copy link
Member

Choose a reason for hiding this comment

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

It should match however we handle other class parameters like socket factories.

We should also either add an "authManagerPluginArg" parameter to allow the caller to customize the instantiated class, e.g. new FooAuthPlugin(String arg).

Otherwise we'd be relying on them passing extra properties into the Properties object that we would ignore in the Driver itself. But if there's no standard for naming those, there's no guarantee we don't eventually add them as "real" properties. Maybe a prefixed name like "ext." to signify end user properties that we'll allow to exist in the Properties map but otherwise ignore.

Copy link
Member Author

Choose a reason for hiding this comment

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

Adding args to the plugin is interesting. We would have to pass the arg into getAuthenticationPlugin
So one option is to provide a second getAuthenticationPlugin(Properties props, Object arg) ?

Copy link
Member

Choose a reason for hiding this comment

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

No the Properties alone is fine as the user can retrieve the value if they want it. They just need a factory name, like sslfactoryarg but specifically for this.

We could add a separate constructor that takes a String (again similar to the SSL factories) but it's not strictly necessary.

Copy link
Member

Choose a reason for hiding this comment

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

Should we just follow the same pattern as the SSL factories (and use the ObjectFactory logic)?

Copy link
Member

Choose a reason for hiding this comment

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

Yes that's a good idea. All this class instantiation should happen in one place so the ClassLoader et al are handled consistently.

} catch (Exception ex ) {
LOGGER.log(Level.FINE, "Unable to load Authentication Plugin " + ex.toString() );
throw ex;
}
}
}
}
13 changes: 4 additions & 9 deletions pgjdbc/src/main/java/org/postgresql/core/ConnectionFactory.java
Expand Up @@ -35,21 +35,19 @@ public abstract class ConnectionFactory {
*
* @param hostSpecs at least one host and port to connect to; multiple elements for round-robin
* failover
* @param user the username to authenticate with; may not be null.
* @param database the database on the server to connect to; may not be null.
* @param info extra properties controlling the connection; notably, "password" if present
* supplies the password to authenticate with.
* @return the new, initialized, connection
* @throws SQLException if the connection could not be established.
*/
public static QueryExecutor openConnection(HostSpec[] hostSpecs, String user,
String database, Properties info) throws SQLException {
public static QueryExecutor openConnection(HostSpec[] hostSpecs,
Properties info) throws SQLException {
String protoName = PGProperty.PROTOCOL_VERSION.get(info);

if (protoName == null || protoName.isEmpty() || "3".equals(protoName)) {
ConnectionFactory connectionFactory = new ConnectionFactoryImpl();
QueryExecutor queryExecutor = connectionFactory.openConnectionImpl(
hostSpecs, user, database, info);
hostSpecs, info);
if (queryExecutor != null) {
return queryExecutor;
}
Expand All @@ -66,17 +64,14 @@ public static QueryExecutor openConnection(HostSpec[] hostSpecs, String user,
*
* @param hostSpecs at least one host and port to connect to; multiple elements for round-robin
* failover
* @param user the username to authenticate with; may not be null.
* @param database the database on the server to connect to; may not be null.
* @param info extra properties controlling the connection; notably, "password" if present
* supplies the password to authenticate with.
* @return the new, initialized, connection, or <code>null</code> if this protocol version is not
* supported by the server.
* @throws SQLException if the connection could not be established for a reason other than
* protocol version incompatibility.
*/
public abstract QueryExecutor openConnectionImpl(HostSpec[] hostSpecs, String user,
String database, Properties info) throws SQLException;
public abstract QueryExecutor openConnectionImpl(HostSpec[] hostSpecs, Properties info) throws SQLException;

/**
* Safely close the given stream.
Expand Down
@@ -0,0 +1,31 @@
/*
* 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.util.GT;
import org.postgresql.util.PSQLException;
import org.postgresql.util.PSQLState;

import java.nio.charset.StandardCharsets;
import java.util.Properties;

public class PasswordAuthentication implements AuthenticationPlugin {

@Override
public byte[] getEncodedPassword(Properties info) throws PSQLException {
String password = PGProperty.PASSWORD.getSetString(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 @@ -67,11 +67,10 @@ public abstract class QueryExecutorBase implements QueryExecutor {
= new TreeMap<String,String>(String.CASE_INSENSITIVE_ORDER);

@SuppressWarnings({"assignment.type.incompatible", "argument.type.incompatible"})
protected QueryExecutorBase(PGStream pgStream, String user,
String database, int cancelSignalTimeout, Properties info) throws SQLException {
protected QueryExecutorBase(PGStream pgStream, int cancelSignalTimeout, Properties info) throws SQLException {
this.pgStream = pgStream;
this.user = user;
this.database = database;
this.user = PGProperty.USER.get(info);
this.database = PGProperty.PG_DBNAME.get(info);
this.cancelSignalTimeout = cancelSignalTimeout;
this.reWriteBatchedInserts = PGProperty.REWRITE_BATCHED_INSERTS.getBoolean(info);
this.columnSanitiserDisabled = PGProperty.DISABLE_COLUMN_SANITISER.getBoolean(info);
Expand Down
Expand Up @@ -9,6 +9,8 @@
import static org.postgresql.util.internal.Nullness.castNonNull;

import org.postgresql.PGProperty;
import org.postgresql.core.AuthenticationPlugin;
import org.postgresql.core.AuthenticationPluginManager;
import org.postgresql.core.ConnectionFactory;
import org.postgresql.core.PGStream;
import org.postgresql.core.QueryExecutor;
Expand Down Expand Up @@ -91,11 +93,21 @@ private ISSPIClient createSSPI(PGStream pgStream,
}
}

private PGStream tryConnect(String user, String database,
Properties info, SocketFactory socketFactory, HostSpec hostSpec,
private PGStream tryConnect(Properties info, SocketFactory socketFactory, HostSpec hostSpec,
SslMode sslMode, GSSEncMode gssEncMode)
throws SQLException, IOException {

int connectTimeout = PGProperty.CONNECT_TIMEOUT.getInt(info) * 1000;
String user = PGProperty.USER.get(info);
String database = PGProperty.PG_DBNAME.get(info);
if (user == null) {
throw new PSQLException(GT.tr("User cannot be null"),
PSQLState.INVALID_NAME);
}
if (database == null) {
throw new PSQLException(GT.tr("Database cannot be null"),
PSQLState.INVALID_NAME);
}

PGStream newStream = new PGStream(socketFactory, hostSpec, connectTimeout);

Expand Down Expand Up @@ -143,7 +155,7 @@ private PGStream tryConnect(String user, String database,
LOGGER.log(Level.FINE, "Send Buffer Size is {0}", newStream.getSocket().getSendBufferSize());
}

newStream = enableGSSEncrypted(newStream, gssEncMode, hostSpec.getHost(), user, info, connectTimeout);
newStream = enableGSSEncrypted(newStream, gssEncMode, hostSpec.getHost(), info, connectTimeout);

// if we have a security context then gss negotiation succeeded. Do not attempt SSL negotiation
if (!newStream.isGssEncrypted()) {
Expand All @@ -166,8 +178,7 @@ private PGStream tryConnect(String user, String database,
}

@Override
public QueryExecutor openConnectionImpl(HostSpec[] hostSpecs, String user, String database,
Properties info) throws SQLException {
public QueryExecutor openConnectionImpl(HostSpec[] hostSpecs, Properties info) throws SQLException {
SslMode sslMode = SslMode.of(info);
GSSEncMode gssEncMode = GSSEncMode.of(info);

Expand Down Expand Up @@ -212,7 +223,7 @@ public QueryExecutor openConnectionImpl(HostSpec[] hostSpecs, String user, Strin
PGStream newStream = null;
try {
try {
newStream = tryConnect(user, database, info, socketFactory, hostSpec, sslMode, gssEncMode);
newStream = tryConnect(info, socketFactory, hostSpec, sslMode, gssEncMode);
} catch (SQLException e) {
if (sslMode == SslMode.PREFER
&& PSQLState.INVALID_AUTHORIZATION_SPECIFICATION.getState().equals(e.getSQLState())) {
Expand All @@ -221,14 +232,13 @@ public QueryExecutor openConnectionImpl(HostSpec[] hostSpecs, String user, Strin
Throwable ex = null;
try {
newStream =
tryConnect(user, database, info, socketFactory, hostSpec, SslMode.DISABLE,gssEncMode);
tryConnect(info, socketFactory, hostSpec, SslMode.DISABLE,gssEncMode);
LOGGER.log(Level.FINE, "Downgraded to non-encrypted connection for host {0}",
hostSpec);
} catch (SQLException ee) {
} catch (SQLException | IOException ee) {
ex = ee;
} catch (IOException ee) {
ex = ee; // Can't use multi-catch in Java 6 :(
davecramer marked this conversation as resolved.
Show resolved Hide resolved
}

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 All @@ -242,7 +252,7 @@ public QueryExecutor openConnectionImpl(HostSpec[] hostSpecs, String user, Strin
Throwable ex = null;
try {
newStream =
tryConnect(user, database, info, socketFactory, hostSpec, SslMode.REQUIRE, gssEncMode);
tryConnect(info, socketFactory, hostSpec, SslMode.REQUIRE, gssEncMode);
LOGGER.log(Level.FINE, "Upgraded to encrypted connection for host {0}",
hostSpec);
} catch (SQLException ee) {
Expand All @@ -268,8 +278,7 @@ public QueryExecutor openConnectionImpl(HostSpec[] hostSpecs, String user, Strin
// CheckerFramework can't infer newStream is non-nullable
castNonNull(newStream);
// Do final startup.
QueryExecutor queryExecutor = new QueryExecutorImpl(newStream, user, database,
cancelSignalTimeout, info);
QueryExecutor queryExecutor = new QueryExecutorImpl(newStream, cancelSignalTimeout, info);

// Check Primary or Secondary
HostStatus hostStatus = HostStatus.ConnectOK;
Expand Down Expand Up @@ -413,7 +422,7 @@ private boolean credentialCacheExists() {
return Unsafe.credentialCacheExists();
}

private PGStream enableGSSEncrypted(PGStream pgStream, GSSEncMode gssEncMode, String host, String user, Properties info,
private PGStream enableGSSEncrypted(PGStream pgStream, GSSEncMode gssEncMode, String host, Properties info,
int connectTimeout)
throws IOException, PSQLException {

Expand All @@ -435,6 +444,11 @@ private PGStream enableGSSEncrypted(PGStream pgStream, GSSEncMode gssEncMode, St
}
}

String user = PGProperty.USER.get(info);
if (user == null) {
throw new PSQLException("GSSAPI encryption required but was impossible user is null", PSQLState.CONNECTION_REJECTED);
}

// attempt to acquire a GSS encrypted connection
String password = PGProperty.PASSWORD.get(info);
LOGGER.log(Level.FINEST, " FE=> GSSENCRequest");
Expand Down Expand Up @@ -595,6 +609,13 @@ 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

AuthenticationPlugin authenticationPlugin;
try {
authenticationPlugin = AuthenticationPluginManager.getAuthenticationPlugin(info);
} catch ( Exception ex ) {
throw new PSQLException(ex.getMessage(), PSQLState.UNEXPECTED_ERROR);
}

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

/* SSPI negotiation state, if used */
Expand Down Expand Up @@ -665,14 +686,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 = authenticationPlugin.getEncodedPassword(info);

pgStream.sendChar('p');
pgStream.sendInteger4(4 + encodedPassword.length + 1);
Expand Down
Expand Up @@ -141,9 +141,9 @@ public class QueryExecutorImpl extends QueryExecutorBase {

@SuppressWarnings({"assignment.type.incompatible", "argument.type.incompatible",
"method.invocation.invalid"})
public QueryExecutorImpl(PGStream pgStream, String user, String database,
public QueryExecutorImpl(PGStream pgStream,
int cancelSignalTimeout, Properties info) throws SQLException, IOException {
super(pgStream, user, database, cancelSignalTimeout, info);
super(pgStream, cancelSignalTimeout, info);

long maxResultBuffer = pgStream.getMaxResultBuffer();
this.adaptiveFetchCache = new AdaptiveFetchCache(maxResultBuffer, info);
Expand Down
20 changes: 20 additions & 0 deletions pgjdbc/src/main/java/org/postgresql/ds/common/BaseDataSource.java
Expand Up @@ -1305,6 +1305,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.core.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
4 changes: 1 addition & 3 deletions pgjdbc/src/main/java/org/postgresql/jdbc/PgConnection.java
Expand Up @@ -203,8 +203,6 @@ public void setFlushCacheOnDeallocate(boolean flushCacheOnDeallocate) {
//
@SuppressWarnings({"method.invocation.invalid", "argument.type.incompatible"})
public PgConnection(HostSpec[] hostSpecs,
String user,
String database,
Properties info,
String url) throws SQLException {
// Print out the driver version number
Expand All @@ -222,7 +220,7 @@ public PgConnection(HostSpec[] hostSpecs,
}

// Now make the initial connection and set up local state
this.queryExecutor = ConnectionFactory.openConnection(hostSpecs, user, database, info);
this.queryExecutor = ConnectionFactory.openConnection(hostSpecs, info);

// WARNING for unsupported servers (8.1 and lower are not supported)
if (LOGGER.isLoggable(Level.WARNING) && !haveMinimumServerVersion(ServerVersion.v8_2)) {
Expand Down