Skip to content

Commit c42d663

Browse files
sharmagotrootmkottakota1
authored
Add TOTP MFA authentication support to Python driver (#570)
Co-authored-by: root <[email protected]> Co-authored-by: mkottakota1 <[email protected]>
1 parent 9aba2d1 commit c42d663

File tree

4 files changed

+235
-11
lines changed

4 files changed

+235
-11
lines changed

.github/workflows/ci.yaml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -408,15 +408,15 @@ jobs:
408408
RAW=$(printf "%s" "$RAW" | sed -n '$p')
409409
410410
# Validate RAW is JSON
411+
# Validate JSON; do NOT exit — allow retry
411412
if ! printf '%s' "$RAW" | python3 -c 'import sys,json; json.load(sys.stdin)' >/dev/null 2>&1; then
412-
echo "Token endpoint did not return valid JSON:"
413-
printf '%s\n' "$RAW"
414-
exit 1
413+
echo "Token endpoint did not return valid JSON, retrying..."
414+
TOKEN=""
415+
else
416+
# Extract token only if JSON is valid
417+
TOKEN=$(printf '%s' "$RAW" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("access_token", ""))')
415418
fi
416419
417-
# Extract token (without printing it)
418-
TOKEN=$(printf '%s' "$RAW" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("access_token", ""))')
419-
420420
if [ -n "$TOKEN" ] && [ "$TOKEN" != "null" ]; then
421421
echo "Access token retrieved successfully."
422422
break

setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
# version should use the format 'x.x.x' (instead of 'vx.x.x')
4646
setup(
4747
name='vertica-python',
48-
version='1.4.0',
48+
version='1.5.0',
4949
description='Official native Python client for the Vertica database.',
5050
long_description="vertica-python is the official Vertica database client for the Python programming language. Please check the [project homepage](https://github.com/vertica/vertica-python) for the details.",
5151
long_description_content_type='text/markdown',
@@ -59,6 +59,7 @@
5959
python_requires=">=3.8",
6060
install_requires=[
6161
'python-dateutil>=1.5',
62+
'pyotp>=2.9.0',
6263
],
6364
classifiers=[
6465
"Development Status :: 5 - Production/Stable",

vertica_python/tests/integration_tests/test_authentication.py

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,121 @@ def test_oauth_access_token(self):
123123
cur.execute("SELECT authentication_method FROM sessions WHERE session_id=(SELECT current_session())")
124124
res = cur.fetchone()
125125
self.assertEqual(res[0], 'OAuth')
126+
# -------------------------------
127+
# TOTP Authentication Test for Vertica-Python Driver
128+
# -------------------------------
129+
import os
130+
import pyotp
131+
from io import StringIO
132+
import sys
126133

127134

128-
exec(AuthenticationTestCase.createPrepStmtClass())
135+
# Positive TOTP Test (Like SHA512 format)
136+
def totp_positive_scenario(self):
137+
with self._connect() as conn:
138+
cur = conn.cursor()
139+
140+
cur.execute("DROP USER IF EXISTS totp_user")
141+
cur.execute("DROP AUTHENTICATION IF EXISTS totp_auth CASCADE")
142+
143+
try:
144+
# Create user with MFA
145+
cur.execute("CREATE USER totp_user IDENTIFIED BY 'password' ENFORCEMFA")
146+
147+
# Grant authentication
148+
# Note: METHOD is 'trusted' or 'password' depending on how MFA is enforced in Vertica
149+
cur.execute("CREATE AUTHENTICATION totp_auth METHOD 'password' HOST '0.0.0.0/0'")
150+
cur.execute("GRANT AUTHENTICATION totp_auth TO totp_user")
151+
152+
# Generate TOTP
153+
TOTP_SECRET = "O5D7DQICJTM34AZROWHSAO4O53ELRJN3"
154+
totp_code = pyotp.TOTP(TOTP_SECRET).now()
155+
156+
# Set connection info
157+
self._conn_info['user'] = 'totp_user'
158+
self._conn_info['password'] = 'password'
159+
self._conn_info['totp'] = totp_code
160+
161+
# Try connection
162+
with self._connect() as totp_conn:
163+
c = totp_conn.cursor()
164+
c.execute("SELECT 1")
165+
res = c.fetchone()
166+
self.assertEqual(res[0], 1)
167+
168+
finally:
169+
cur.execute("DROP USER IF EXISTS totp_user")
170+
cur.execute("DROP AUTHENTICATION IF EXISTS totp_auth CASCADE")
171+
172+
# Negative Test: Missing TOTP
173+
def totp_missing_code_scenario(self):
174+
with self._connect() as conn:
175+
cur = conn.cursor()
176+
177+
cur.execute("DROP USER IF EXISTS totp_user")
178+
cur.execute("DROP AUTHENTICATION IF EXISTS totp_auth CASCADE")
179+
180+
try:
181+
cur.execute("CREATE USER totp_user IDENTIFIED BY 'password' ENFORCEMFA")
182+
cur.execute("CREATE AUTHENTICATION totp_auth METHOD 'password' HOST '0.0.0.0/0'")
183+
cur.execute("GRANT AUTHENTICATION totp_auth TO totp_user")
184+
185+
self._conn_info['user'] = 'totp_user'
186+
self._conn_info['password'] = 'password'
187+
self._conn_info.pop('totp', None) # No TOTP
188+
189+
err_msg = "TOTP was requested but not provided"
190+
self.assertConnectionFail(err_msg=err_msg)
191+
192+
finally:
193+
cur.execute("DROP USER IF EXISTS totp_user")
194+
cur.execute("DROP AUTHENTICATION IF EXISTS totp_auth CASCADE")
195+
196+
# Negative Test: Invalid TOTP Format
197+
def totp_invalid_format_scenario(self):
198+
with self._connect() as conn:
199+
cur = conn.cursor()
200+
201+
cur.execute("DROP USER IF EXISTS totp_user")
202+
cur.execute("DROP AUTHENTICATION IF EXISTS totp_auth CASCADE")
203+
204+
try:
205+
cur.execute("CREATE USER totp_user IDENTIFIED BY 'password' ENFORCEMFA")
206+
cur.execute("CREATE AUTHENTICATION totp_auth METHOD 'password' HOST '0.0.0.0/0'")
207+
cur.execute("GRANT AUTHENTICATION totp_auth TO totp_user")
208+
209+
self._conn_info['user'] = 'totp_user'
210+
self._conn_info['password'] = 'password'
211+
self._conn_info['totp'] = "123" # Invalid
212+
213+
err_msg = "Invalid TOTP format"
214+
self.assertConnectionFail(err_msg=err_msg)
215+
216+
finally:
217+
cur.execute("DROP USER IF EXISTS totp_user")
218+
cur.execute("DROP AUTHENTICATION IF EXISTS totp_auth CASCADE")
219+
220+
# Negative Test: Wrong TOTP (Valid format, wrong value)
221+
def totp_wrong_code_scenario(self):
222+
with self._connect() as conn:
223+
cur = conn.cursor()
224+
225+
cur.execute("DROP USER IF EXISTS totp_user")
226+
cur.execute("DROP AUTHENTICATION IF EXISTS totp_auth CASCADE")
227+
228+
try:
229+
cur.execute("CREATE USER totp_user IDENTIFIED BY 'password' ENFORCEMFA")
230+
cur.execute("CREATE AUTHENTICATION totp_auth METHOD 'password' HOST '0.0.0.0/0'")
231+
cur.execute("GRANT AUTHENTICATION totp_auth TO totp_user")
232+
233+
self._conn_info['user'] = 'totp_user'
234+
self._conn_info['password'] = 'password'
235+
self._conn_info['totp'] = "999999" # Wrong OTP
236+
237+
err_msg = "Invalid TOTP"
238+
self.assertConnectionFail(err_msg=err_msg)
239+
240+
finally:
241+
cur.execute("DROP USER IF EXISTS totp_user")
242+
cur.execute("DROP AUTHENTICATION IF EXISTS totp_auth CASCADE")
243+

vertica_python/vertica/connection.py

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@
4444
import ssl
4545
import uuid
4646
import warnings
47+
import re
48+
import time
49+
import signal
50+
import select
51+
import sys
4752
from collections import deque
4853
from struct import unpack
4954

@@ -303,6 +308,13 @@ def __init__(self, options: Optional[Dict[str, Any]] = None) -> None:
303308
self.address_list = _AddressList(self.options['host'], self.options['port'],
304309
self.options['backup_server_node'], self._logger)
305310

311+
# TOTP support
312+
self.totp = self.options.get('totp')
313+
if self.totp is not None:
314+
if not isinstance(self.totp, str):
315+
raise TypeError('The value of connection option "totp" should be a string')
316+
self._logger.info('TOTP received in connection options')
317+
306318
# OAuth authentication setup
307319
self.options.setdefault('oauth_access_token', DEFAULT_OAUTH_ACCESS_TOKEN)
308320
if not isinstance(self.options['oauth_access_token'], str):
@@ -918,16 +930,112 @@ def startup_connection(self) -> None:
918930
else:
919931
auth_category = ''
920932

921-
self.write(messages.Startup(user, database, session_label, os_user_name, autocommit, binary_transfer,
922-
request_complex_types, oauth_access_token, workload, auth_category))
933+
# Check if user has provided TOTP in options
934+
totp = self.options.get("totp", None)
935+
retried_totp = False
936+
937+
def send_startup(totp_value=None):
938+
self.write(messages.Startup(
939+
user, database, session_label, os_user_name,
940+
autocommit, binary_transfer, request_complex_types,
941+
oauth_access_token, workload, auth_category,
942+
totp_value
943+
))
944+
923945

946+
send_startup(totp_value=totp) # ✅ First attempt
924947
while True:
925948
message = self.read_message()
926-
949+
self._logger.debug(f"Received message: {type(message).__name__}")
950+
self._logger.debug(f"Message code: {getattr(message, 'code', None)}")
927951
if isinstance(message, messages.Authentication):
928952
if message.code == messages.Authentication.OK:
929953
self._logger.info("User {} successfully authenticated"
930954
.format(self.options['user']))
955+
# 🔁 Continue reading messages after successful authentication
956+
while True:
957+
message = self.read_message()
958+
self._logger.debug(f"Post-auth message: {type(message).__name__}")
959+
if isinstance(message, messages.ReadyForQuery):
960+
self.transaction_status = message.transaction_status
961+
# self.session_id = message.session_id
962+
self._logger.info("Connection is ready")
963+
break
964+
elif isinstance(message, messages.ParameterStatus):
965+
self.parameters[message.key] = message.value
966+
elif isinstance(message, messages.BackendKeyData):
967+
self.backend_pid = message.pid
968+
self.backend_key = message.key
969+
elif isinstance(message, messages.ErrorResponse):
970+
error_msg = message.error_message()
971+
972+
# Extract only the "Message: ..." part
973+
match = re.search(r'Message: (.+?)(?:, Sqlstate|$)', error_msg, re.DOTALL)
974+
short_msg = match.group(1).strip() if match else error_msg.strip()
975+
976+
if "Invalid TOTP" in short_msg:
977+
print("Authentication failed: Invalid TOTP token.")
978+
self._logger.error("Authentication failed: Invalid TOTP token.")
979+
self.close_socket()
980+
raise errors.ConnectionError("Authentication failed: Invalid TOTP token.")
981+
982+
# Generic error fallback
983+
print(f"Authentication failed: {short_msg}")
984+
self._logger.error(short_msg)
985+
raise errors.ConnectionError(f"Authentication failed: {short_msg}")
986+
else:
987+
self._logger.warning(f"Unexpected message type: {type(message).__name__}")
988+
989+
break
990+
elif message.code == messages.Authentication.TOTP:
991+
if retried_totp:
992+
raise errors.ConnectionError("TOTP authentication failed.")
993+
994+
# ✅ If TOTP not provided initially, prompt only once
995+
if not totp:
996+
timeout_seconds = 30 # 5 minutes timeout
997+
try:
998+
print("Enter TOTP: ", end="", flush=True)
999+
ready, _, _ = select.select([sys.stdin], [], [], timeout_seconds)
1000+
if ready:
1001+
totp_input = sys.stdin.readline().strip()
1002+
1003+
# ❌ Blank TOTP entered
1004+
if not totp_input:
1005+
self._logger.error("Invalid TOTP: Cannot be empty.")
1006+
raise errors.ConnectionError("Invalid TOTP: Cannot be empty.")
1007+
1008+
# ❌ Validate TOTP format (must be 6 digits)
1009+
if not totp_input.isdigit() or len(totp_input) != 6:
1010+
print("Invalid TOTP format. Please enter a 6-digit code.")
1011+
self._logger.error("Invalid TOTP format entered.")
1012+
raise errors.ConnectionError("Invalid TOTP format: Must be a 6-digit number.")
1013+
# ✅ Valid TOTP — retry connection
1014+
totp = totp_input
1015+
self.close_socket()
1016+
self.socket = self.establish_socket_connection(self.address_list)
1017+
self._logger.info(f"Retrying with TOTP: '{totp}'")
1018+
1019+
# ✅ Re-init required attributes
1020+
self.backend_pid = 0
1021+
self.backend_key = 0
1022+
self.transaction_status = None
1023+
self.session_id = None
1024+
1025+
self._logger.debug("Startup message sent with TOTP.")
1026+
send_startup(totp_value=totp)
1027+
1028+
else:
1029+
self._logger.error("Session timeout: No TOTP entered within time limit.")
1030+
self.close_socket()
1031+
raise errors.ConnectionError("Session timeout: No TOTP entered within time limit.")
1032+
except (KeyboardInterrupt, EOFError):
1033+
raise errors.ConnectionError("TOTP input cancelled.")
1034+
else:
1035+
raise errors.ConnectionError("TOTP was requested but not provided.")
1036+
retried_totp = True
1037+
continue
1038+
9311039
elif message.code == messages.Authentication.CHANGE_PASSWORD:
9321040
msg = "The password for user {} has expired".format(self.options['user'])
9331041
self._logger.error(msg)

0 commit comments

Comments
 (0)