Index: Doc/library/nntplib.rst =================================================================== --- Doc/library/nntplib.rst (revision 86256) +++ Doc/library/nntplib.rst (working copy) @@ -69,6 +69,18 @@ *readermode* defaults to ``None``. *usenetrc* defaults to ``True``. +.. class:: NNTP_SSL(host, port=563, user=None, password=None, ssl_context=None, readermode=None, usenetrc=True, [timeout]) + + Return a new :class:`NNTP_SSL` object. + :class:`NNTP_SSL` objects have the same methods as :class:`NNTP` objects. + If *port* is omitted, port 563 (NNTPS) is used. *ssl_context* is also + optional, and is an ssl.SSLContext object. All other parameters + behave the same as for :class:`NNTP`. + + Note that SSL-on-563 is discouraged per RFC 4642, in favor of + STARTTLS as described below. However, some servers only support the + former. + .. exception:: NNTPError Derived from the standard exception :exc:`Exception`, this is the base @@ -179,6 +191,34 @@ .. versionadded:: 3.2 +.. method:: NNTP.login(user=None, password=None, usenetrc=True) + + Send ``AUTHINFO`` commands with the user name and password. If *user* + and *password* are None and *usenetrc* is True, credentials from + ~/.netrc will be used if possible. + + Unless intentionally delayed, login is normally performed during an NNTP + object initialization and separately calling this function is unnecessary. + To force authentication to be delayed, you must not set *user* or *password* + when creating the object, and must set *usenetrc* to False. + + .. versionadded:: 3.2 + + +.. method:: NNTP.starttls(ssl_context=None) + + Send a ``STARTTLS`` command. The *ssl_context* argument is optional + and should be a :class:`ssl.SSLContext` object. This will enable + encryption on the NNTP connection. + + Note that this may not be done after authentication information has + been transmitted, and authentication occurs by default if possible during a + :class:`NNTP` object initialization. See :meth:`NNTP.login` for information + on suppressing this behavior. + + .. versionadded:: 3.2 + + .. method:: NNTP.newgroups(date, *, file=None) Send a ``NEWGROUPS`` command. The *date* argument should be a Index: Lib/nntplib.py =================================================================== --- Lib/nntplib.py (revision 86256) +++ Lib/nntplib.py (working copy) @@ -69,6 +69,13 @@ import datetime import warnings +try: + import ssl +except ImportError: + _have_ssl = False +else: + _have_ssl = True + from email.header import decode_header as _email_decode_header from socket import _GLOBAL_DEFAULT_TIMEOUT @@ -111,8 +118,8 @@ # Standard port used by NNTP servers NNTP_PORT = 119 +NNTP_SSL_PORT = 563 - # Response numbers that are followed by additional text (e.g. article) _LONGRESP = { '100', # HELP @@ -262,7 +269,23 @@ date_str = "{0:04d}{1.month:02d}{1.day:02d}".format(y, dt) return date_str, time_str +if _have_ssl: + def _encrypt_on(sock, context): + """Wrap a socket in SSL/TLS. Arguments: + - sock: Socket to wrap + - context: SSL context to use for the encrypted connection + Returns: + - sock: New, encrypted socket. + """ + # Generate a default SSL context if none was passed. + if context is None: + context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) + context.options |= ssl.OP_NO_SSLv2 + # SSLv2 considered harmful. + return context.wrap_socket(sock) + + # The classes themselves class _NNTPBase: # UTF-8 is the character set for all NNTP commands and responses: they @@ -280,18 +303,13 @@ encoding = 'utf-8' errors = 'surrogateescape' - def __init__(self, file, host, user=None, password=None, - readermode=None, usenetrc=True, - timeout=_GLOBAL_DEFAULT_TIMEOUT): + def __init__(self, file, host, + readermode=None, timeout=_GLOBAL_DEFAULT_TIMEOUT): """Initialize an instance. Arguments: - file: file-like object (open for read/write in binary mode) - host: hostname of the server (used if `usenetrc` is True) - - user: username to authenticate with - - password: password to use with username - readermode: if true, send 'mode reader' command after connecting. - - usenetrc: allow loading username and password from ~/.netrc file - if not specified explicitly - timeout: timeout (in seconds) used for socket connections readermode is sometimes necessary if you are connecting to an @@ -300,75 +318,33 @@ unexpected NNTPPermanentErrors, you might need to set readermode. """ + self.host = host self.file = file self.debugging = 0 self.welcome = self._getresp() # 'mode reader' is sometimes necessary to enable 'reader' mode. # However, the order in which 'mode reader' and 'authinfo' need to - # arrive differs between some NNTP servers. Try to send - # 'mode reader', and if it fails with an authorization failed - # error, try again after sending authinfo. - readermode_afterauth = 0 + # arrive differs between some NNTP servers. If setreadermode() fails + # with an authorization failed error, it will set this to true; + # the login() routine will interpret that as a request to try again + # after performing its normal function. + self.readermode_afterauth = False if readermode: - try: - self.welcome = self._shortcmd('mode reader') - except NNTPPermanentError: - # error 500, probably 'not implemented' - pass - except NNTPTemporaryError as e: - if user and e.response.startswith('480'): - # Need authorization before 'mode reader' - readermode_afterauth = 1 - else: - raise - # If no login/password was specified, try to get them from ~/.netrc - # Presume that if .netc has an entry, NNRP authentication is required. - try: - if usenetrc and not user: - import netrc - credentials = netrc.netrc() - auth = credentials.authenticators(host) - if auth: - user = auth[0] - password = auth[2] - except IOError: - pass - # Perform NNTP authentication if needed. - if user: - resp = self._shortcmd('authinfo user '+user) - if resp.startswith('381'): - if not password: - raise NNTPReplyError(resp) - else: - resp = self._shortcmd( - 'authinfo pass '+password) - if not resp.startswith('281'): - raise NNTPPermanentError(resp) - if readermode_afterauth: - try: - self.welcome = self._shortcmd('mode reader') - except NNTPPermanentError: - # error 500, probably 'not implemented' - pass + self.setreadermode() - # Inquire about capabilities (RFC 3977) - self.nntp_version = 1 - self.nntp_implementation = None - try: - resp, caps = self.capabilities() - except NNTPPermanentError: - # Server doesn't support capabilities - self._caps = {} - else: - self._caps = caps - if 'VERSION' in caps: - # The server can advertise several supported versions, - # choose the highest. - self.nntp_version = max(map(int, caps['VERSION'])) - if 'IMPLEMENTATION' in caps: - self.nntp_implementation = ' '.join(caps['IMPLEMENTATION']) + # RFC4642 2.2.2: Both the client and the server MUST know if there is + # a TLS session active. A client MUST NOT attempt to start a TLS + # session if a TLS session is already active. + self.tls_on = False + # Inquire about capabilities (RFC 3977). + self._caps = None + self.getcapabilities() + + # Log in and encryption setup order is left to subclasses. + self.authenticated = False + def getwelcome(self): """Get the welcome message from the server (this is read and squirreled away by __init__()). @@ -382,6 +358,22 @@ """Get the server capabilities, as read by __init__(). If the CAPABILITIES command is not supported, an empty dict is returned.""" + if self._caps is None: + self.nntp_version = 1 + self.nntp_implementation = None + try: + resp, caps = self.capabilities() + except NNTPPermanentError: + # Server doesn't support capabilities + self._caps = {} + else: + self._caps = caps + if 'VERSION' in caps: + # The server can advertise several supported versions, + # choose the highest. + self.nntp_version = max(map(int, caps['VERSION'])) + if 'IMPLEMENTATION' in caps: + self.nntp_implementation = ' '.join(caps['IMPLEMENTATION']) return self._caps def set_debuglevel(self, level): @@ -917,8 +909,78 @@ finally: self._close() return resp + + def login(self, user=None, password=None, usenetrc=True): + if self.authenticated: + raise ValueError("Already logged in.") + # If no login/password was specified but netrc was requested, + # try to get them from ~/.netrc + # Presume that if .netc has an entry, NNRP authentication is required. + try: + if usenetrc and not user: + import netrc + credentials = netrc.netrc() + auth = credentials.authenticators(host) + if auth: + user = auth[0] + password = auth[2] + except IOError: + pass + # Perform NNTP authentication if needed. + if user: + resp = self._shortcmd('authinfo user '+user) + if resp.startswith('381'): + if not password: + raise NNTPReplyError(resp) + else: + resp = self._shortcmd( + 'authinfo pass '+password) + if not resp.startswith('281'): + raise NNTPPermanentError(resp) + # Attempt to send mode reader if it was requested after login. + if self.readermode_afterauth: + self.setreadermode() + + def setreadermode(self): + try: + self.welcome = self._shortcmd('mode reader') + except NNTPPermanentError: + # error 500, probably 'not implemented' + pass + except NNTPTemporaryError as e: + if user and e.response.startswith('480'): + # Need authorization before 'mode reader' + self.readermode_afterauth = True + else: + raise + if _have_ssl: + def starttls(self, context=None): + """Process a STARTTLS command. Arguments: + - context: SSL context to use for the encrypted connection + """ + # Per RFC4642, STARTTLS MUST NOT be sent after authentication or if + # a TLS session already exists. + if self.tls_on: + raise ValueError("TLS is already enabled.") + if self.authenticated: + raise ValueError("TLS cannot be started after authentication.") + if 'STARTTLS' not in self._caps: + raise NNTPPermanentError("TLS not supported by server.") + resp = self._shortcmd('STARTTLS') + if resp.startswith('382'): + self.file.close() + self.sock = _encrypt_on(self.sock, context) + self.file = self.sock.makefile("rwb") + self.tls_on = True + # Capabilities may change after TLS starts up, so ask for them + # again. + self._caps = None + self.getcapabilities() + else: + raise NNTPError("TLS failed to start.") + class NNTP(_NNTPBase): def __init__(self, host, port=NNTP_PORT, user=None, password=None, @@ -945,8 +1007,9 @@ self.port = port self.sock = socket.create_connection((host, port), timeout) file = self.sock.makefile("rwb") - _NNTPBase.__init__(self, file, host, user, password, - readermode, usenetrc, timeout) + _NNTPBase.__init__(self, file, host, + readermode, timeout) + self.login(user, password, usenetrc) def _close(self): try: @@ -954,7 +1017,33 @@ finally: self.sock.close() + +if _have_ssl: + class NNTP_SSL(_NNTPBase): + def __init__(self, host, port=NNTP_SSL_PORT, + user=None, password=None, ssl_context=None, + readermode=None, usenetrc=True, + timeout=_GLOBAL_DEFAULT_TIMEOUT): + """This works identically to NNTP.__init__, except for the change + in default port and the context argument for SSL connections. + """ + self.sock = socket.create_connection((host, port), timeout) + self.sock = _encrypt_on(self.sock, ssl_context) + file = self.sock.makefile("rwb") + _NNTPBase.__init__(self, file, host, + readermode=readermode, timeout=timeout) + self.login(user, password, usenetrc) + + def _close(self): + try: + _NNTPBase._close(self) + finally: + self.sock.close() + + __all__.append("NNTP_SSL") + + # Test retrieval when run as a script. if __name__ == '__main__': import argparse @@ -970,9 +1059,21 @@ help='NNTP port number (default: %(default)s)') parser.add_argument('-n', '--nb-articles', default=10, type=int, help='number of articles to fetch (default: %(default)s)') + parser.add_argument('-l', '--ssl', action='store_true', default=False, + help='test nntps on port 563 instead') args = parser.parse_args() - s = NNTP(host=args.server, port=args.port) + if not args.ssl: + s = NNTP(host=args.server, port=args.port) + else: + port = args.port + if port == NNTP_PORT: + port = NNTP_SSL_PORT + s = NNTP_SSL(host=args.server, port=port) + + caps = s.getcapabilities() + if 'STARTLS' in caps: + s.starttls() resp, count, first, last, name = s.group(args.group) print('Group', name, 'has', count, 'articles, range', first, 'to', last) Index: Lib/test/test_nntplib.py =================================================================== --- Lib/test/test_nntplib.py (revision 86256) +++ Lib/test/test_nntplib.py (working copy) @@ -4,7 +4,7 @@ import unittest import contextlib from test import support -from nntplib import NNTP, GroupInfo +from nntplib import NNTP, GroupInfo, _have_ssl import nntplib TIMEOUT = 30 @@ -161,6 +161,35 @@ def test_quit(self): self.server.quit() self.server = None + + def test_login(self): + baduser = "notarealuser" + badpw = "notarealpassword" + # Check that bogus credentials cause failure + self.assertRaises(nntplib.NNTPError, self.server.login, + user=baduser, password=badpw, usenetrc=False) + # FIXME: We should check that correct credentials succeed, but that + # would require valid details for some server somewhere to be in the + # test suite, I think. Gmane is anonymous, at least as used for the + # other tests. + + if _have_ssl: + def test_starttls(self): + file = self.server.file + sock = self.server.sock + try: + self.server.starttls() + except nntplib.NNTPPermanentError: + self.skipTest("STARTTLS not supported by server.") + else: + # Check that the socket and internal psuedo-file really were + # changed. + assertNotEqual(file, self.server.file) + assertNotEqual(sock, self.server.sock) + # Check that the new socket really is an SSL one + assertIsInstance(self.server.sock, ssl.SSLSocket) + # Check that trying starttls when it's already active fails. + self.assertRaises(ValueError, self.server.starttls) class NetworkedNNTPTests(NetworkedNNTPTestsMixin, unittest.TestCase): @@ -195,6 +224,18 @@ _check_caps(caps) +if _have_ssl: + class NetworkedNNTP_SSLTests(NetworkedNNTPTests): + NNTP_HOST = 'snews.gmane.org' + def setUp(self): + support.requires("network") + with support.transient_internet(self.NNTP_HOST): + self.server = nntplib.NNTP_SSL(self.NNTP_HOST, timeout=TIMEOUT, + usenetrc=False) + # disabled as the connection will already be encrypted. + test_starttls = None + + # # Non-networked tests using a local server (or something mocking it). # @@ -261,7 +302,6 @@ # Using BufferedRWPair instead of BufferedRandom ensures the file # isn't seekable. file = io.BufferedRWPair(self.sio, self.sio) - kwargs.setdefault('usenetrc', False) self.server = nntplib._NNTPBase(file, 'test.server', *args, **kwargs) return self.server @@ -1134,9 +1174,10 @@ def test_main(): - support.run_unittest(MiscTests, NNTPv1Tests, NNTPv2Tests, - NetworkedNNTPTests - ) + tests = [MiscTests, NNTPv1Tests, NNTPv2Tests, NetworkedNNTPTests] + if _have_ssl: + tests.append(NetworkedNNTP_SSLTests) + support.run_unittest(*tests) if __name__ == "__main__":