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

Added support for preparing the encrypted password (PQencryptPasswordConn) #576

Merged
merged 13 commits into from
May 20, 2018
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions doc/src/extensions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,38 @@ Other functions
.. __: http://www.postgresql.org/docs/current/static/libpq-exec.html#LIBPQ-PQESCAPEIDENTIFIER


.. method:: encrypt_password(password, user, scope=None, algorithm=None)

Return the encrypted form of a PostgreSQL password.

:param password: the cleartext password to encrypt
:param user: the name of the user to use the password for
:param scope: the scope to encrypt the password into; if *algorithm* is
``md5`` it can be `!None`
:type scope: `connection` or `cursor`
:param algorithm: the password encryption algorithm to use

The *algorithm* ``md5`` is always supported. Other algorithms are only
supported if the client libpq version is at least 10 and may require a
compatible server version: check the `PostgreSQL encryption
documentation`__ to know the algorithms supported by your server.

.. __: https://www.postgresql.org/docs/current/static/encryption-options.html

Using `!None` as *algorithm* will result in querying the server to know the
current server password encryption setting, which is a blocking operation:
query the server separately and specify a value for *algorithm* if you
want to maintain a non-blocking behaviour.

.. versionadded:: 2.8

.. seealso:: PostgreSQL docs for the `password_encryption`__ setting, libpq `PQencryptPasswordConn()`__, `PQencryptPassword()`__ functions.

.. __: https://www.postgresql.org/docs/current/static/runtime-config-connection.html#GUC-PASSWORD-ENCRYPTION
.. __: https://www.postgresql.org/docs/current/static/libpq-misc.html#LIBPQ-PQENCRYPTPASSWORDCONN
.. __: https://www.postgresql.org/docs/current/static/libpq-misc.html#LIBPQ-PQENCRYPTPASSWORD



.. index::
pair: Isolation level; Constants
Expand Down
2 changes: 1 addition & 1 deletion lib/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
string_types, binary_types, new_type, new_array_type, register_type,
ISQLQuote, Notify, Diagnostics, Column,
QueryCanceledError, TransactionRollbackError,
set_wait_callback, get_wait_callback, )
set_wait_callback, get_wait_callback, encrypt_password, )


"""Isolation level values."""
Expand Down
101 changes: 101 additions & 0 deletions psycopg/psycopgmodule.c
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,105 @@ psyco_libpq_version(PyObject *self)
#endif
}

/* encrypt_password - Prepare the encrypted password form */
#define psyco_encrypt_password_doc \
"encrypt_password(password, user, [scope], [algorithm]) -- Prepares the encrypted form of a PostgreSQL password.\n\n"

static PyObject *
psyco_encrypt_password(PyObject *self, PyObject *args, PyObject *kwargs)
{
char *encrypted = NULL;
PyObject *password = NULL, *user = NULL;
PyObject *scope = Py_None, *algorithm = Py_None;
PyObject *res = NULL;
connectionObject *conn = NULL;

static char *kwlist[] = {"password", "user", "scope", "algorithm", NULL};

if (!PyArg_ParseTupleAndKeywords(args, kwargs, "OO|OO", kwlist,
&password, &user, &scope, &algorithm)) {
return NULL;
}

/* for ensure_bytes */
Py_INCREF(user);
Py_INCREF(password);
Py_INCREF(algorithm);

if (scope != Py_None) {
if (PyObject_TypeCheck(scope, &cursorType)) {
conn = ((cursorObject*)scope)->conn;
}
else if (PyObject_TypeCheck(scope, &connectionType)) {
conn = (connectionObject*)scope;
}
else {
PyErr_SetString(PyExc_TypeError,
"the scope must be a connection or a cursor");
goto exit;
}
}

if (!(user = psycopg_ensure_bytes(user))) { goto exit; }
if (!(password = psycopg_ensure_bytes(password))) { goto exit; }
if (algorithm != Py_None) {
if (!(algorithm = psycopg_ensure_bytes(algorithm))) {
goto exit;
}
}

/* If we have to encrypt md5 we can use the libpq < 10 API */
if (algorithm != Py_None &&
strcmp(Bytes_AS_STRING(algorithm), "md5") == 0) {
encrypted = PQencryptPassword(
Bytes_AS_STRING(password), Bytes_AS_STRING(user));
}

/* If the algorithm is not md5 we have to use the API available from
* libpq 10. */
else {
#if PG_VERSION_NUM >= 100000
if (!conn) {
PyErr_SetString(ProgrammingError,
"password encryption (other than 'md5' algorithm)"
" requires a connection or cursor");
goto exit;
}

/* TODO: algo = None will block: forbid on async/green conn? */
encrypted = PQencryptPasswordConn(conn->pgconn,
Bytes_AS_STRING(password), Bytes_AS_STRING(user),
algorithm != Py_None ? Bytes_AS_STRING(algorithm) : NULL);
#else
PyErr_SetString(NotSupportedError,
"password encryption (other than 'md5' algorithm)"
" requires libpq 10");
goto exit;
#endif
}

if (encrypted) {
res = Text_FromUTF8(encrypted);
}
else {
const char *msg = PQerrorMessage(conn->pgconn);
PyErr_Format(ProgrammingError,
"password encryption failed: %s", msg ? msg : "no reason given");
goto exit;
}

exit:
if (encrypted) {
PQfreemem(encrypted);
}
Py_XDECREF(user);
Py_XDECREF(password);
Py_XDECREF(algorithm);

return res;
}


/* psyco_encodings_fill

Fill the module's postgresql<->python encoding table */
Expand Down Expand Up @@ -856,6 +955,8 @@ static PyMethodDef psycopgMethods[] = {
METH_O, psyco_set_wait_callback_doc},
{"get_wait_callback", (PyCFunction)psyco_get_wait_callback,
METH_NOARGS, psyco_get_wait_callback_doc},
{"encrypt_password", (PyCFunction)psyco_encrypt_password,
METH_VARARGS|METH_KEYWORDS, psyco_encrypt_password_doc},

{NULL, NULL, 0, NULL} /* Sentinel */
};
Expand Down
90 changes: 89 additions & 1 deletion tests/test_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@
from .testutils import (
unittest, decorate_all_tests, skip_if_no_superuser,
skip_before_postgres, skip_after_postgres, skip_before_libpq,
ConnectingTestCase, skip_if_tpc_disabled, skip_if_windows, slow)
ConnectingTestCase, skip_if_tpc_disabled, skip_if_windows, slow,
libpq_version
)

from .testconfig import dsn, dbname

Expand Down Expand Up @@ -1406,6 +1408,92 @@ def test_idempotence_check(self):
self.assertEqual(cur.fetchone()[0], 'on')


class TestEncryptPassword(ConnectingTestCase):
@skip_before_postgres(10)
def test_encrypt_password_post_9_6(self):
cur = self.conn.cursor()
cur.execute("SHOW password_encryption;")
server_encryption_algorithm = cur.fetchone()[0]

# MD5 algorithm
self.assertEqual(
ext.encrypt_password('psycopg2', 'ashesh', self.conn, 'md5'),
'md594839d658c28a357126f105b9cb14cfc'
)

# keywords
self.assertEqual(
ext.encrypt_password(
password='psycopg2', user='ashesh',
scope=self.conn, algorithm='md5'),
'md594839d658c28a357126f105b9cb14cfc'
)
if libpq_version() < 100000:
self.assertRaises(
psycopg2.NotSupportedError,
ext.encrypt_password, 'psycopg2', 'ashesh', self.conn,
'scram-sha-256'
)
else:
enc_password = ext.encrypt_password(
'psycopg2', 'ashesh', self.conn
)
if server_encryption_algorithm == 'md5':
self.assertEqual(
enc_password, 'md594839d658c28a357126f105b9cb14cfc'
)
elif server_encryption_algorithm == 'scram-sha-256':
self.assertEqual(enc_password[:14], 'SCRAM-SHA-256$')

self.assertEqual(
ext.encrypt_password(
'psycopg2', 'ashesh', self.conn, 'scram-sha-256'
)[:14], 'SCRAM-SHA-256$'
)

self.assertRaises(psycopg2.ProgrammingError,
ext.encrypt_password, 'psycopg2', 'ashesh', self.conn, 'abc')

@skip_after_postgres(10)
def test_encrypt_password_pre_10(self):
self.assertEqual(
ext.encrypt_password('psycopg2', 'ashesh', self.conn),
'md594839d658c28a357126f105b9cb14cfc'
)

self.assertRaises(psycopg2.ProgrammingError,
ext.encrypt_password, 'psycopg2', 'ashesh', self.conn, 'abc')

def test_encrypt_md5(self):
self.assertEqual(
ext.encrypt_password('psycopg2', 'ashesh', algorithm='md5'),
'md594839d658c28a357126f105b9cb14cfc'
)

def test_encrypt_scram(self):
if libpq_version() >= 100000:
self.assert_(
ext.encrypt_password(
'psycopg2', 'ashesh', self.conn, 'scram-sha-256')
.startswith('SCRAM-SHA-256$'))
else:
self.assertRaises(psycopg2.NotSupportedError,
ext.encrypt_password,
password='psycopg2', user='ashesh',
scope=self.conn, algorithm='scram-sha-256')

def test_bad_types(self):
self.assertRaises(TypeError, ext.encrypt_password)
self.assertRaises(TypeError, ext.encrypt_password,
'password', 42, self.conn, 'md5')
self.assertRaises(TypeError, ext.encrypt_password,
42, 'user', self.conn, 'md5')
self.assertRaises(TypeError, ext.encrypt_password,
42, 'user', 'wat', 'abc')
self.assertRaises(TypeError, ext.encrypt_password,
'password', 'user', 'wat', 42)


class AutocommitTests(ConnectingTestCase):
def test_closed(self):
self.conn.close()
Expand Down