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

Add plugin manager to allow dynamically supplying passwords #2369

Merged
merged 4 commits into from Dec 28, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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.
Copy link
Member

Choose a reason for hiding this comment

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

Frankly speaking, I believe the naming is misleading. What the thing does it supplies passwords, so it is not clear why do we call it authenticationPlugin rather than passwordCallback or something like that.

There's a similar interface in Java: javax.security.auth.callback.PasswordCallback


## 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
16 changes: 1 addition & 15 deletions pgjdbc/src/main/java/org/postgresql/Driver.java
Expand Up @@ -465,7 +465,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 @@ -732,20 +732,6 @@ private static HostSpec[] hostSpecs(Properties props) {
return hostSpecs;
}

/**
* @return the username of the URL
*/
private static String user(Properties props) {
return props.getProperty("user", "");
}

/**
* @return the database name of the URL
*/
private static String database(Properties props) {
return props.getProperty("PGDBNAME", "");
}

/**
* @return the timeout from the URL, in milliseconds
*/
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,
Copy link
Member

Choose a reason for hiding this comment

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

Does it make sense to cache the instance ? Is it possible that different connections would use different authentication plugins ?

Copy link
Member Author

Choose a reason for hiding this comment

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

No it should not be cached as connections should be isolated from each other. If a user needs to maintain state across multiple runtime instantiations then it would need to be done in the user's implementation through either static fields or some kind of proxying.

Copy link
Member Author

Choose a reason for hiding this comment

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

Thinking about that a bit more, perhaps we need a new opaque "arg" property that users can use to customize their implementation. The plugin constructor takes the connection properties but there's no specific property that user's could use for their own purposes. Something like authPluginArg (e.g. the plugin equivalent of socketFactoryArg).

Copy link
Member

Choose a reason for hiding this comment

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

Fairly good chance that the user will only use one auth plugin. I wonder what the cost of instantiating a class really is (in other words is it worth worrying about)

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes I doubt it's an issue in practice and connections tend to be long lived anyway. The time spent opening the underlying socket and TLS handshake is going to be significantly more than any object creation.

Copy link
Member

Choose a reason for hiding this comment

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

Fair enough.

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);
}
}
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
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,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 @@ -93,11 +95,18 @@ 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);
try {
Expand Down Expand Up @@ -152,8 +161,7 @@ private PGStream tryConnect(String user, String database,
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
Expand Down Expand Up @@ -181,8 +189,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 @@ -227,7 +234,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 @@ -236,14 +243,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 :(
}

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 @@ -257,7 +263,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 @@ -283,8 +289,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 @@ -424,7 +429,7 @@ private static String createPostgresTimeZone() {
return start + tz.substring(4);
}

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 @@ -446,8 +451,12 @@ 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");

// Send GSSEncryption request packet
Expand Down Expand Up @@ -485,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 @@ -606,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 @@ -649,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 @@ -676,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 @@ -758,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 @@ -776,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