Navigation Menu

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

Dynamic Passwords in NpgsqlConnection #2502

Merged
merged 13 commits into from Jun 29, 2019
Merged
2 changes: 2 additions & 0 deletions doc/security.md
Expand Up @@ -6,6 +6,8 @@ The simplest way to log into PostgreSQL is by specifying a `Username` and a `Pas

If a `Password` is not specified and your PostgreSQL is configured to request a password (plain or MD5), Npgsql will look for a [standard PostgreSQL password file](https://www.postgresql.org/docs/current/static/libpq-pgpass.html). If you specify the `Passfile` connection string parameter, the file it specifies will be used. If that parameter isn't defined, Npgsql will look under the path taken from `PGPASSFILE` environment variable. If the environment variable isn't defined, Npgsql will fall back to the system-dependent default directory which is `$HOME/.pgpass` for Unix and `%APPDATA%\postgresql\pgpass.conf` for Windows.

`NpgsqlConnection` can also be configured with a `ProvidePasswordCallback`. This will be executed when new database connections are opened to generate a password in code. This can be useful if you are using Amazon Web Services RDS for Postgres which can be configured to use short lived tokens generated based on access credentials. The `ProvidePasswordCallback` delegate is executed when both password and passFile connection string parameters are not specified.

For documentation about all auth methods supported by PostgreSQL, [see this page](http://www.postgresql.org/docs/current/static/auth-methods.html). Note that Npgsql supports Unix-domain sockets (auth method `local`), simply set your `Host` parameter to the absolute path of your PostgreSQL socket directory, as configred in your `postgresql.conf`.

## Integrated Security (GSS/SSPI/Kerberos)
Expand Down
24 changes: 24 additions & 0 deletions src/Npgsql/NpgsqlConnection.cs
Expand Up @@ -330,6 +330,20 @@ public override string ConnectionString
}
}

/// <summary>
/// Gets or sets the delegate used to generate a password for new database connections.
/// </summary>
/// <remarks>
/// This delegate is executed when a new database connection is opened that requires a password.
/// <see cref="NpgsqlConnectionStringBuilder.Password">Password</see> and
/// <see cref="NpgsqlConnectionStringBuilder.Passfile">Passfile</see> connection string
/// properties have precedence over this delegate. It will not be executed if a password is
/// specified, or the specified or default Passfile contains a valid entry.
/// Due to connection pooling this delegate is only executed when a new physical connection
/// is opened, not when reusing a connection that was previously opened from the pool.
/// </remarks>
public ProvidePasswordCallback? ProvidePasswordCallback { get; set; }

#endregion Connection string management

#region Configuration settings
Expand Down Expand Up @@ -1415,5 +1429,15 @@ public void ReloadTypes()
/// <param name="certificates">A <see cref="System.Security.Cryptography.X509Certificates.X509CertificateCollection">X509CertificateCollection</see> to be filled with one or more client certificates.</param>
public delegate void ProvideClientCertificatesCallback(X509CertificateCollection certificates);

/// <summary>
/// Represents the method that allows the application to provide a password at connection time in code rather than configuration
/// </summary>
/// <param name="host">Hostname</param>
/// <param name="port">Port</param>
/// <param name="database">Database Name</param>
/// <param name="username">User</param>
/// <returns>A valid password for connecting to the database</returns>
public delegate string ProvidePasswordCallback(string host, int port, string database, string username);

#endregion
}
31 changes: 22 additions & 9 deletions src/Npgsql/NpgsqlConnector.Auth.cs
Expand Up @@ -27,15 +27,15 @@ async Task Authenticate(string username, NpgsqlTimeout timeout, bool async)
return;

case AuthenticationRequestType.AuthenticationCleartextPassword:
await AuthenticateCleartext(async);
await AuthenticateCleartext(username, async);
return;

case AuthenticationRequestType.AuthenticationMD5Password:
await AuthenticateMD5(username, ((AuthenticationMD5PasswordMessage)msg).Salt, async);
return;

case AuthenticationRequestType.AuthenticationSASL:
await AuthenticateSASL(((AuthenticationSASLMessage)msg).Mechanisms, async);
await AuthenticateSASL(((AuthenticationSASLMessage)msg).Mechanisms, username, async);
return;

case AuthenticationRequestType.AuthenticationGSS:
Expand All @@ -51,9 +51,9 @@ async Task Authenticate(string username, NpgsqlTimeout timeout, bool async)
}
}

async Task AuthenticateCleartext(bool async)
async Task AuthenticateCleartext(string username, bool async)
{
var passwd = GetPassword();
var passwd = GetPassword(username);
if (passwd == null)
throw new NpgsqlException("No password has been provided but the backend requires one (in cleartext)");

Expand All @@ -65,15 +65,15 @@ async Task AuthenticateCleartext(bool async)
Expect<AuthenticationRequestMessage>(await ReadMessage(async));
}

async Task AuthenticateSASL(List<string> mechanisms, bool async)
async Task AuthenticateSASL(List<string> mechanisms, string username, bool async)
{
// At the time of writing PostgreSQL only supports SCRAM-SHA-256
if (!mechanisms.Contains("SCRAM-SHA-256"))
throw new NpgsqlException("No supported SASL mechanism found (only SCRAM-SHA-256 is supported for now). " +
"Mechanisms received from server: " + string.Join(", ", mechanisms));
var mechanism = "SCRAM-SHA-256";

var passwd = GetPassword() ??
var passwd = GetPassword(username) ??
throw new NpgsqlException($"No password has been provided but the backend requires one (in SASL/{mechanism})");

const string ClientKey = "Client Key";
Expand Down Expand Up @@ -179,7 +179,7 @@ static byte[] HMAC(byte[] data, string key)

async Task AuthenticateMD5(string username, byte[] salt, bool async)
{
var passwd = GetPassword();
var passwd = GetPassword(username);
if (passwd == null)
throw new NpgsqlException("No password has been provided but the backend requires one (in MD5)");

Expand Down Expand Up @@ -366,20 +366,33 @@ public override long Position

class AuthenticationCompleteException : Exception { }

string? GetPassword()
string? GetPassword(string username)
roji marked this conversation as resolved.
Show resolved Hide resolved
{
var passwd = Settings.Password;
if (passwd != null)
return passwd;

// No password was provided. Attempt to pull the password from the pgpass file.
var matchingEntry = PgPassFile.Load(Settings.Passfile)?.GetFirstMatchingEntry(Settings.Host, Settings.Port, Settings.Database, Settings.Username);
var matchingEntry = PgPassFile.Load(Settings.Passfile)?.GetFirstMatchingEntry(Host, Port, Settings.Database!, username);
if (matchingEntry != null)
{
Log.Trace("Taking password from pgpass file");
return matchingEntry.Password;
}

if (ProvidePasswordCallback != null)
{
Log.Trace($"Taking password from {nameof(ProvidePasswordCallback)} delegate");
try
{
return ProvidePasswordCallback(Host, Port, Settings.Database!, username);
roji marked this conversation as resolved.
Show resolved Hide resolved
}
catch (Exception e)
{
throw new NpgsqlException($"Obtaining password using {nameof(NpgsqlConnection)}.{nameof(ProvidePasswordCallback)} delegate failed", e);
}
}

return null;
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/Npgsql/NpgsqlConnector.cs
Expand Up @@ -50,6 +50,7 @@ sealed partial class NpgsqlConnector : IDisposable

ProvideClientCertificatesCallback? ProvideClientCertificatesCallback { get; }
RemoteCertificateValidationCallback? UserCertificateValidationCallback { get; }
ProvidePasswordCallback? ProvidePasswordCallback { get; }

internal Encoding TextEncoding { get; private set; } = default!;

Expand Down Expand Up @@ -239,13 +240,15 @@ internal NpgsqlConnector(NpgsqlConnection connection)
Connection.Connector = this;
ProvideClientCertificatesCallback = Connection.ProvideClientCertificatesCallback;
UserCertificateValidationCallback = Connection.UserCertificateValidationCallback;
ProvidePasswordCallback = Connection.ProvidePasswordCallback;
}

NpgsqlConnector(NpgsqlConnector connector)
: this(connector.Settings, connector.ConnectionString)
{
ProvideClientCertificatesCallback = connector.ProvideClientCertificatesCallback;
UserCertificateValidationCallback = connector.UserCertificateValidationCallback;
ProvidePasswordCallback = connector.ProvidePasswordCallback;
}

/// <summary>
Expand Down
102 changes: 102 additions & 0 deletions test/Npgsql.Tests/ConnectionTests.cs
Expand Up @@ -183,6 +183,108 @@ public void AuthenticationFailure()
}
}

#region ProvidePasswordCallback Tests

[Test, Description("ProvidePasswordCallback is used when password is not supplied in connection string")]
public void ProvidePasswordCallbackDelegateIsUsed()
{
var connString = new NpgsqlConnectionStringBuilder(ConnectionString)
{
Pooling = false //testing opening of connections, pooling will return an existing connection
};
var goodPassword = connString.Password;
connString.Password = null;

bool getPasswordDelegateWasCalled = false;

using (var conn = new NpgsqlConnection(connString.ToString()) { ProvidePasswordCallback = ProvidePasswordCallback })
{
conn.Open();
Assert.True(getPasswordDelegateWasCalled, "ProvidePasswordCallback delegate not used");
}

string ProvidePasswordCallback(string host, int port, string database, string username)
{
getPasswordDelegateWasCalled = true;
return goodPassword;
}
}

[Test, Description("ProvidePasswordCallback is not used when password is supplied in connection string")]
public void ProvidePasswordCallbackDelegateIsNotUsed()
{
var connString = new NpgsqlConnectionStringBuilder(ConnectionString)
{
Pooling = false //testing opening of connections, pooling will return an existing connection
};

using (var conn = new NpgsqlConnection(connString.ToString()) { ProvidePasswordCallback = ProvidePasswordCallback })
{
Assert.DoesNotThrow(() => conn.Open());
}

string ProvidePasswordCallback(string host, int port, string database, string username)
{
throw new Exception("password should come from connection string, not delegate");
}
}

[Test, Description("Exceptions thrown from client application are wrapped when using ProvidePasswordCallback Delegate")]
public void ProvidePasswordCallbackDelegateExceptionsAreWrapped()
{
var connString = new NpgsqlConnectionStringBuilder(ConnectionString)
{
Pooling = false, //testing opening of connections, pooling will return an existing connection
Password = null
};

using (var conn = new NpgsqlConnection(connString.ToString()) { ProvidePasswordCallback = ProvidePasswordCallback })
{
Assert.That(() => conn.Open(), Throws.Exception
.TypeOf<NpgsqlException>()
.With.InnerException.Message.EqualTo("inner exception from ProvidePasswordCallback")
);
}

string ProvidePasswordCallback(string host, int port, string database, string username)
{
throw new Exception("inner exception from ProvidePasswordCallback");
}
}

[Test, Description("Parameters passed to ProvidePasswordCallback delegate are correct")]
public void ProvidePasswordCallbackDelegateGetsCorrectArguments()
{
var connString = new NpgsqlConnectionStringBuilder(ConnectionString) { Pooling = false };
var goodPassword = connString.Password;
connString.Password = null;

string? receivedHost = null;
int? receivedPort = null;
string? receivedDatabase = null;
string? receivedUsername = null;

using (var conn = new NpgsqlConnection(connString.ToString()) { ProvidePasswordCallback = ProvidePasswordCallback })
{
conn.Open();
Assert.AreEqual(connString.Host, receivedHost);
Assert.AreEqual(connString.Port, receivedPort);
Assert.AreEqual(connString.Database, receivedDatabase);
Assert.AreEqual(connString.Username, receivedUsername);
}

string ProvidePasswordCallback(string host, int port, string database, string username)
{
receivedHost = host;
receivedPort = port;
receivedDatabase = database;
receivedUsername = username;

return goodPassword;
}
}
#endregion

[Test]
public void BadDatabase()
{
Expand Down