Skip to content

Commit 7c26c9d

Browse files
committed
feat: pop3 lib support auth
1 parent 2a820e2 commit 7c26c9d

File tree

4 files changed

+135
-2
lines changed

4 files changed

+135
-2
lines changed

Doc/library/poplib.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,18 @@ A :class:`POP3` instance has the following methods:
245245
.. versionadded:: 3.4
246246

247247

248+
.. method:: POP3.auth(mechanism, authobject=None, initial_response=None)
249+
250+
Authenticate using the POP3 ``AUTH`` command as specified in :rfc:`5034`.
251+
252+
If *initial_response* is provided (``bytes`` or ``str``), it is
253+
base64-encoded and appended to the command after a single space.
254+
255+
If *authobject* is provided, it is called with the server’s ``bytes``
256+
challenge (already base64-decoded) and must return the client response
257+
(``bytes`` or ``str``). Return ``b'*'`` to abort the exchange.
258+
259+
248260
Instances of :class:`POP3_SSL` have no additional methods. The interface of this
249261
subclass is identical to its parent.
250262

Lib/poplib.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import re
1818
import socket
1919
import sys
20+
import base64
2021

2122
try:
2223
import ssl
@@ -217,6 +218,71 @@ def pass_(self, pswd):
217218
"""
218219
return self._shortcmd('PASS %s' % pswd)
219220

221+
def auth(self, mechanism, authobject=None, initial_response=None):
222+
"""Authenticate to the POP3 server using the AUTH command (RFC 5034).
223+
224+
Parameters
225+
----------
226+
mechanism : str
227+
SASL mechanism name, e.g. 'PLAIN', 'CRAM-MD5'.
228+
authobject : callable, optional
229+
Challenge-response callback. Called with a `bytes` challenge
230+
(already base64-decoded) and must return `bytes` or `str`.
231+
Return ``b'*'`` to abort the exchange.
232+
initial_response : bytes or str, optional
233+
Initial client response to send immediately after the AUTH command.
234+
If you supply this you must **not** supply *authobject*.
235+
236+
Returns
237+
-------
238+
bytes
239+
The server's final line (``+OK ...`` or ``-ERR ...``).
240+
241+
Raises
242+
------
243+
ValueError
244+
If both *authobject* and *initial_response* are given.
245+
"""
246+
if authobject is not None and initial_response is not None:
247+
raise ValueError('authobject and initial_response are mutually exclusive')
248+
249+
if initial_response is not None:
250+
if isinstance(initial_response, str):
251+
initial_response = initial_response.encode(self.encoding)
252+
b64 = base64.b64encode(initial_response).decode('ascii')
253+
return self._shortcmd(f'AUTH {mechanism} {b64}'.rstrip())
254+
255+
if authobject is None:
256+
return self._shortcmd(f'AUTH {mechanism}')
257+
258+
self._putcmd(f'AUTH {mechanism}')
259+
while True:
260+
resp = self._getresp()
261+
if resp[:3] == b'+OK':
262+
return resp
263+
264+
challenge_b64 = resp[1:].lstrip(b' ')
265+
if challenge_b64:
266+
try:
267+
challenge = base64.b64decode(challenge_b64)
268+
except Exception:
269+
padded = challenge_b64 + b'=' * (-len(challenge_b64) % 4)
270+
challenge = base64.b64decode(padded, validate=False)
271+
else:
272+
challenge = b''
273+
274+
response = authobject(challenge)
275+
if response is None:
276+
response = b''
277+
if isinstance(response, str):
278+
response = response.encode(self.encoding)
279+
280+
if response == b'*':
281+
self._putcmd('*')
282+
return self._getresp()
283+
284+
self._putcmd(base64.b64encode(response).decode('ascii'))
285+
220286

221287
def stat(self):
222288
"""Get mailbox status.

Lib/test/test_poplib.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
# Modified by Giampaolo Rodola' to give poplib.POP3 and poplib.POP3_SSL
44
# a real test suite
5-
5+
import base64
66
import poplib
77
import socket
88
import os
@@ -49,7 +49,7 @@
4949

5050
class DummyPOP3Handler(asynchat.async_chat):
5151

52-
CAPAS = {'UIDL': [], 'IMPLEMENTATION': ['python-testlib-pop-server']}
52+
CAPAS = {'UIDL': [], 'SASL': ['PLAIN'], 'IMPLEMENTATION': ['python-testlib-pop-server']}
5353
enable_UTF8 = False
5454

5555
def __init__(self, conn):
@@ -59,6 +59,8 @@ def __init__(self, conn):
5959
self.push('+OK dummy pop3 server ready. <timestamp>')
6060
self.tls_active = False
6161
self.tls_starting = False
62+
self._auth_pending = False
63+
self._auth_mech = None
6264

6365
def collect_incoming_data(self, data):
6466
self.in_buffer.append(data)
@@ -67,6 +69,20 @@ def found_terminator(self):
6769
line = b''.join(self.in_buffer)
6870
line = str(line, 'ISO-8859-1')
6971
self.in_buffer = []
72+
73+
if self._auth_pending:
74+
self._auth_pending = False
75+
if line == '*':
76+
self.push('-ERR authentication cancelled')
77+
return
78+
try:
79+
base64.b64decode(line.encode('ascii'))
80+
except Exception:
81+
self.push('-ERR invalid base64')
82+
return
83+
self.push('+OK Logged in.')
84+
return
85+
7086
cmd = line.split(' ')[0].lower()
7187
space = line.find(' ')
7288
if space != -1:
@@ -85,6 +101,28 @@ def handle_error(self):
85101
def push(self, data):
86102
asynchat.async_chat.push(self, data.encode("ISO-8859-1") + b'\r\n')
87103

104+
def cmd_auth(self, arg):
105+
parts = arg.split()
106+
if not parts:
107+
self.push('-ERR missing mechanism')
108+
return
109+
mech = parts[0].upper()
110+
if mech != 'PLAIN':
111+
self.push('-ERR unsupported mechanism')
112+
return
113+
if len(parts) >= 2:
114+
try:
115+
base64.b64decode(parts[1].encode('ascii'))
116+
except Exception:
117+
self.push('-ERR invalid base64')
118+
return
119+
self.push('+OK Logged in.')
120+
else:
121+
self._auth_pending = True
122+
self._auth_mech = mech
123+
self.in_buffer.clear()
124+
self.push('+ ')
125+
88126
def cmd_echo(self, arg):
89127
# sends back the received string (used by the test suite)
90128
self.push(arg)
@@ -286,6 +324,22 @@ def test_pass_(self):
286324
self.assertOK(self.client.pass_('python'))
287325
self.assertRaises(poplib.error_proto, self.client.user, 'invalid')
288326

327+
def test_auth_plain_initial_response(self):
328+
secret = b"user\x00adminuser\x00password"
329+
resp = self.client.auth("PLAIN", initial_response=secret)
330+
self.assertStartsWith(resp, b"+OK")
331+
332+
def test_auth_plain_challenge_response(self):
333+
secret = b"user\x00adminuser\x00password"
334+
def authobject(challenge):
335+
return secret
336+
resp = self.client.auth("PLAIN", authobject=authobject)
337+
self.assertStartsWith(resp, b"+OK")
338+
339+
def test_auth_rejects_conflicting_args(self):
340+
with self.assertRaises(ValueError):
341+
self.client.auth("PLAIN", authobject=lambda c: b"x", initial_response=b"y")
342+
289343
def test_stat(self):
290344
self.assertEqual(self.client.stat(), (10, 100))
291345

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add RFC 5034 AUTH support to poplib

0 commit comments

Comments
 (0)