mercurial_keyring.py 32.8 KB
Newer Older
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# -*- coding: utf-8 -*-
#
# mercurial_keyring: save passwords in password database
#
# Copyright (c) 2009 Marcin Kasperski <Marcin.Kasperski@mekk.waw.pl>
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the distribution.
# 3. The name of the author may not be used to endorse or promote products
#    derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
30
# See README.rst for more details.
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
31

32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
'''securely save HTTP and SMTP passwords to encrypted storage

mercurial_keyring securely saves HTTP and SMTP passwords in password
databases (Gnome Keyring, KDE KWallet, OSXKeyChain, Win32 crypto
services).

The process is automatic.  Whenever bare Mercurial just prompts for
the password, Mercurial with mercurial_keyring enabled checks whether
saved password is available first.  If so, it is used.  If not, you
will be prompted for the password, but entered password will be
saved for the future use.

In case saved password turns out to be invalid (HTTP or SMTP login
fails) it is dropped, and you are asked for current password.

Actual password storage is implemented by Python keyring library, this
extension glues those services to Mercurial. Consult keyring
documentation for information how to configure actual password
backend (by default keyring guesses, usually correctly, for example
you get KDE Wallet under KDE, and Gnome Keyring under Gnome or Unity).
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
52
53
54
55
56
'''

import socket
import os
import sys
Marcin Kasperski's avatar
Marcin Kasperski committed
57
import re
58
59
60
61
62
63
64
if sys.version_info[0] < 3:
    from urllib2 import (
        HTTPPasswordMgrWithDefaultRealm, AbstractBasicAuthHandler, AbstractDigestAuthHandler)
else:
    from urllib.request import (
        HTTPPasswordMgrWithDefaultRealm, AbstractBasicAuthHandler, AbstractDigestAuthHandler)
import smtplib
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
65

Marcin Kasperski's avatar
Marcin Kasperski committed
66
from mercurial import util, sslutil, error
67
68
69
70
71
from mercurial.i18n import _
from mercurial.url import passwordmgr
from mercurial import mail
from mercurial.mail import SMTPS, STARTTLS
from mercurial import encoding
72
from mercurial import ui as uimod
73

Marcin Kasperski's avatar
Marcin Kasperski committed
74
# pylint: disable=invalid-name, line-too-long, protected-access, too-many-arguments
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103

###########################################################################
# Specific import trickery
###########################################################################


def import_meu():
    """
    Convoluted import of mercurial_extension_utils, which helps
    TortoiseHg/Win setups. This routine and it's use below
    performs equivalent of
        from mercurial_extension_utils import monkeypatch_method
    but looks for some non-path directories.
    """
    try:
        import mercurial_extension_utils
    except ImportError:
        my_dir = os.path.dirname(__file__)
        sys.path.extend([
            # In the same dir (manual or site-packages after pip)
            my_dir,
            # Developer clone
            os.path.join(os.path.dirname(my_dir), "extension_utils"),
            # Side clone
            os.path.join(os.path.dirname(my_dir), "mercurial-extension_utils"),
        ])
        try:
            import mercurial_extension_utils
        except ImportError:
Marcin Kasperski's avatar
Marcin Kasperski committed
104
            raise error.Abort(_("""Can not import mercurial_extension_utils.
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
105
Please install this module in Python path.
106
See Installation chapter in https://foss.heptapod.net/mercurial/mercurial_keyring/ for details
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
107
108
109
(and for info about TortoiseHG on Windows, or other bundled Python)."""))
    return mercurial_extension_utils

Marcin Kasperski's avatar
PEP    
Marcin Kasperski committed
110

Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
meu = import_meu()
monkeypatch_method = meu.monkeypatch_method


def import_keyring():
    """
    Importing keyring happens to be costly if wallet is slow, so we delay it
    until it is really needed. The routine below also works around various
    demandimport-related problems.
    """
    # mercurial.demandimport incompatibility workaround.
    # various keyring backends fail as they can't properly import helper
    # modules (as demandimport modifies python import behaviour).
    # If you get import errors with demandimport in backtrace, try
    # guessing what to block and extending the list below.
126
127
    mod, was_imported_now = meu.direct_import_ext(
        "keyring", [
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
128
129
130
131
132
133
            "gobject._gobject",
            "configparser",
            "json",
            "abc",
            "io",
            "keyring",
134
135
136
137
138
139
            "keyring.backends.SecretService",
            "keyring.backends.Windows",
            "keyring.backends.chainer",
            "keyring.backends.fail",
            "keyring.backends.kwallet",
            "keyring.backends.macOS",
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
140
141
142
143
144
145
146
147
148
149
            "gdata.docs.service",
            "gdata.service",
            "types",
            "atom.http",
            "atom.http_interface",
            "atom.service",
            "atom.token_store",
            "ctypes",
            "secretstorage.exceptions",
            "fs.opener",
150
151
152
153
154
            "win32ctypes.pywin32",
            "win32ctypes.pywin32.pywintypes",
            "win32ctypes.pywin32.win32cred",
            "pywintypes",
            "win32cred",
155
156
157
158
159
        ])
    if was_imported_now:
        # Shut up warning about uninitialized logging by keyring
        meu.disable_logging("keyring")
    return mod
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
160

Marcin Kasperski's avatar
PEP    
Marcin Kasperski committed
161

Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
#################################################################
# Actual implementation
#################################################################

KEYRING_SERVICE = "Mercurial"


class PasswordStore(object):
    """
    Helper object handling keyring usage (password save&restore,
    the way passwords are keyed in the keyring).
    """
    def __init__(self):
        self.cache = dict()

    def get_http_password(self, url, username):
        """
        Checks whether password of username for url is available,
        returns it or None
        """
        return self._read_password_from_keyring(
            self._format_http_key(url, username))

    def set_http_password(self, url, username, password):
        """Saves password to keyring"""
        self._save_password_to_keyring(
            self._format_http_key(url, username),
            password)

    def clear_http_password(self, url, username):
        """Drops saved password"""
193
        self.set_http_password(url, username, b"")
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
194
195
196
197

    @staticmethod
    def _format_http_key(url, username):
        """Construct actual key for password identification"""
198
199
200
201
202
203
204
205
206
207
208
209
        # keyring expects str, mercurial feeds as here mostly with bytes
        key = "%s@@%s" % (meu.pycompat.sysstr(username),
                          meu.pycompat.sysstr(url))
        return key

    @staticmethod
    def _format_smtp_key(machine, port, username):
        """Construct key for SMTP password identification"""
        key = "%s@@%s:%s" % (meu.pycompat.sysstr(username),
                             meu.pycompat.sysstr(machine),
                             str(port))
        return key
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230

    def get_smtp_password(self, machine, port, username):
        """Checks for SMTP password in keyring, returns
        password or None"""
        return self._read_password_from_keyring(
            self._format_smtp_key(machine, port, username))

    def set_smtp_password(self, machine, port, username, password):
        """Saves SMTP password to keyring"""
        self._save_password_to_keyring(
            self._format_smtp_key(machine, port, username),
            password)

    def clear_smtp_password(self, machine, port, username):
        """Drops saved SMTP password"""
        self.set_smtp_password(machine, port, username, "")

    @staticmethod
    def _read_password_from_keyring(pwdkey):
        """Physically read from keyring"""
        keyring = import_keyring()
231
232
233
234
        try:
            password = keyring.get_password(KEYRING_SERVICE, pwdkey)
        except Exception as err:
            ui = uimod.ui()
235
236
237
238
            ui.warn(meu.ui_string(
                "keyring: keyring backend doesn't seem to work, password can not be restored. Falling back to prompts. Error details: %s\n",
                err))
            return b''
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
239
        # Reverse recoding from next routine
240
        if isinstance(password, meu.pycompat.unicode):
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
241
242
243
244
245
246
247
248
            return encoding.tolocal(password.encode('utf-8'))
        return password

    @staticmethod
    def _save_password_to_keyring(pwdkey, password):
        """Physically write to keyring"""
        keyring = import_keyring()
        # keyring in general expects unicode.
Marcin Kasperski's avatar
Marcin Kasperski committed
249
250
251
252
253
254
        # Mercurial provides "local" encoding. See #33.
        # py3 adds even more fun as we get already unicode from getpass.
        # To handle those quirks, let go through encoding.fromlocal in case we
        # got bytestr here, this will handle both normal py2 and emergency py3 cases.
        if isinstance(password, bytes):
            password = encoding.fromlocal(password).decode('utf-8')
255
256
257
258
259
        try:
            keyring.set_password(
                KEYRING_SERVICE, pwdkey, password)
        except Exception as err:
            ui = uimod.ui()
Marcin Kasperski's avatar
Marcin Kasperski committed
260
261
262
            ui.warn(meu.ui_string(
                "keyring: keyring backend doesn't seem to work, password was not saved. Error details: %s\n", 
                err))
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
263

Marcin Kasperski's avatar
PEP    
Marcin Kasperski committed
264

Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
265
266
267
268
password_store = PasswordStore()


############################################################
269
# Various utils
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
270
271
############################################################

272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
class PwdCache(object):
    """Short term cache, used to preserve passwords
    if they are used twice during a command"""
    def __init__(self):
        self._cache = {}

    def store(self, realm, url, user, pwd):
        """Saves password"""
        cache_key = (realm, url, user)
        self._cache[cache_key] = pwd

    def check(self, realm, url, user):
        """Checks for cached password"""
        cache_key = (realm, url, user)
        return self._cache.get(cache_key)


289
_re_http_url = re.compile(b'^https?://')
290

Marcin Kasperski's avatar
PEP    
Marcin Kasperski committed
291

292
293
294
def is_http_path(url):
    return bool(_re_http_url.search(url))

295
296
297
298
299
300
301
302

def make_passwordmgr(ui):
    """Constructing passwordmgr in a way compatible with various mercurials"""
    if hasattr(ui, 'httppasswordmgrdb'):
        return passwordmgr(ui, ui.httppasswordmgrdb)
    else:
        return passwordmgr(ui)

Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
303
############################################################
304
# HTTP password management
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
305
306
307
308
309
310
311
312
313
314
315
############################################################


class HTTPPasswordHandler(object):
    """
    Actual implementation of password handling (user prompting,
    configuration file searching, keyring save&restore).

    Object of this class is bound as passwordmgr attribute.
    """
    def __init__(self):
316
        self.pwd_cache = PwdCache()
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
317
318
        self.last_reply = None

319
    # Markers and also names used in debug notes. Password source
320
321
322
    SRC_URL = "repository URL"
    SRC_CFGAUTH = "hgrc"
    SRC_MEMCACHE = "temporary cache"
323
    SRC_URLCACHE = "urllib temporary cache"
324
325
    SRC_KEYRING = "keyring"

326
    def get_credentials(self, pwmgr, realm, authuri, skip_caches=False):
327
328
329
330
        """
        Looks up for user credentials in various places, returns them
        and information about their source.

331
332
333
334
        Used internally inside find_auth and inside informative
        commands (thiis method doesn't cache, doesn't detect bad
        passwords etc, doesn't prompt interactively, doesn't store
        password in keyring).
335
336

        Returns: user, password, SRC_*, actual_url
337
338
339

        If not found, password and SRC is None, user can be given or
        not, url is always set
340
341
342
        """
        ui = pwmgr.ui

343
        parsed_url, url_user, url_passwd = self.unpack_url(authuri)
344
        base_url = bytes(parsed_url)
Marcin Kasperski's avatar
Marcin Kasperski committed
345
346
        ui.debug(meu.ui_string('keyring: base url: %s, url user: %s, url pwd: %s\n',
                               base_url, url_user, url_passwd and b'******' or b''))
347

348
        # Extract username (or password) stored directly in url
349
350
351
352
        if url_user and url_passwd:
            return url_user, url_passwd, self.SRC_URL, base_url

        # Extract data from urllib (in case it was already stored)
353
        if isinstance(pwmgr, HTTPPasswordMgrWithDefaultRealm):
354
            urllib_user, urllib_pwd = \
355
                HTTPPasswordMgrWithDefaultRealm.find_user_password(
356
357
358
359
                    pwmgr, realm, authuri)
        else:
            urllib_user, urllib_pwd = pwmgr.passwddb.find_user_password(
                realm, authuri)
360
361
        if urllib_user and urllib_pwd:
            return urllib_user, urllib_pwd, self.SRC_URLCACHE, base_url
362

363
364
        actual_user = url_user or urllib_user

365
366
367
        # Consult configuration to normalize url to prefix, and find username
        # (and maybe password)
        auth_user, auth_pwd, keyring_url = self.get_url_config(
368
369
            ui, parsed_url, actual_user)
        if auth_user and actual_user and (actual_user != auth_user):
Marcin Kasperski's avatar
Marcin Kasperski committed
370
            raise error.Abort(_('keyring: username for %s specified both in repository path (%s) and in .hg/hgrc/[auth] (%s). Please, leave only one of those' % (base_url, actual_user, auth_user)))
371
372
373
        if auth_user and auth_pwd:
            return auth_user, auth_pwd, self.SRC_CFGAUTH, keyring_url

374
375
        actual_user = actual_user or auth_user

376
        if skip_caches:
377
            return actual_user, None, None, keyring_url
378

379
380
        # Check memory cache (reuse )
        # Checking the memory cache (there may be many http calls per command)
381
        cached_pwd = self.pwd_cache.check(realm, keyring_url, actual_user)
382
        if cached_pwd:
383
            return actual_user, cached_pwd, self.SRC_MEMCACHE, keyring_url
384
385

        # Load from keyring.
386
        if actual_user:
Marcin Kasperski's avatar
Marcin Kasperski committed
387
388
            ui.debug(meu.ui_string("keyring: looking for password (user %s, url %s)\n",
                                   actual_user, keyring_url))
389
            keyring_pwd = password_store.get_http_password(keyring_url, actual_user)
390
            if keyring_pwd:
391
                return actual_user, keyring_pwd, self.SRC_KEYRING, keyring_url
392

393
        return actual_user, None, None, keyring_url
394

395
396
397
    @staticmethod
    def prompt_interactively(ui, user, realm, url):
        """Actual interactive prompt"""
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
398
        if not ui.interactive():
Marcin Kasperski's avatar
Marcin Kasperski committed
399
            raise error.Abort(_('keyring: http authorization required but program used in non-interactive mode'))
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
400

401
        if not user:
Marcin Kasperski's avatar
Marcin Kasperski committed
402
            ui.status(meu.ui_string("keyring: username not specified in hgrc (or in url). Password will not be saved.\n"))
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
403

Marcin Kasperski's avatar
Marcin Kasperski committed
404
405
406
407
408
        ui.write(meu.ui_string("http authorization required\n"))
        ui.status(meu.ui_string("realm: %s\n",
                                realm))
        ui.status(meu.ui_string("url: %s\n",
                                url))
409
        if user:
Marcin Kasperski's avatar
Marcin Kasperski committed
410
411
            ui.write(meu.ui_string("user: %s (fixed in hgrc or url)\n", 
                                   user))
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
412
        else:
Marcin Kasperski's avatar
Marcin Kasperski committed
413
414
415
            user = ui.prompt(meu.ui_string("user:"),
                             default=None)
        pwd = ui.getpass(meu.ui_string("password: "))
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
        return user, pwd

    def find_auth(self, pwmgr, realm, authuri, req):
        """
        Actual implementation of find_user_password - different
        ways of obtaining the username and password.

        Returns pair username, password
        """
        ui = pwmgr.ui
        after_bad_auth = self._after_bad_auth(ui, realm, authuri, req)

        # Look in url, cache, etc
        user, pwd, src, final_url = self.get_credentials(
            pwmgr, realm, authuri, skip_caches=after_bad_auth)
        if pwd:
            if src != self.SRC_MEMCACHE:
                self.pwd_cache.store(realm, final_url, user, pwd)
            self._note_last_reply(realm, authuri, user, req)
Marcin Kasperski's avatar
Marcin Kasperski committed
435
436
            ui.debug(meu.ui_string("keyring: Password found in %s\n",
                                   src))
437
438
439
440
            return user, pwd

        # Last resort: interactive prompt
        user, pwd = self.prompt_interactively(ui, user, realm, final_url)
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
441

442
        if user:
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
443
444
445
446
            # Saving password to the keyring.
            # It is done only if username is permanently set.
            # Otherwise we won't be able to find the password so it
            # does not make much sense to preserve it
Marcin Kasperski's avatar
Marcin Kasperski committed
447
448
            ui.debug(meu.ui_string("keyring: Saving password for %s to keyring\n",
                                   user))
449
450
            try:
                password_store.set_http_password(final_url, user, pwd)
451
            except Exception as e:
ataumoefolau's avatar
ataumoefolau committed
452
453
454
                keyring = import_keyring()
                if isinstance(e, keyring.errors.PasswordSetError):
                    ui.traceback()
Marcin Kasperski's avatar
Marcin Kasperski committed
455
                    ui.warn(meu.ui_string("warning: failed to save password in keyring\n"))
ataumoefolau's avatar
ataumoefolau committed
456
457
                else:
                    raise e
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
458
459

        # Saving password to the memory cache
460
        self.pwd_cache.store(realm, final_url, user, pwd)
461
        self._note_last_reply(realm, authuri, user, req)
Marcin Kasperski's avatar
Marcin Kasperski committed
462
        ui.debug(meu.ui_string("keyring: Manually entered password\n"))
463
        return user, pwd
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
464

465
    def get_url_config(self, ui, parsed_url, user):
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
466
        """
467
468
        Checks configuration to decide whether/which username, prefix,
        and password are configured for given url. Consults [auth] section.
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
469

470
471
472
        Returns tuple (username, password, prefix) containing elements
        found. username and password can be None (if unset), if prefix
        is not found, url itself is returned.
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
473
        """
474
        from mercurial.httpconnection import readauthforuri
Marcin Kasperski's avatar
Marcin Kasperski committed
475
476
        ui.debug(meu.ui_string("keyring: checking for hgrc info about url %s, user %s\n",
                               parsed_url, user))
477
478

        res = readauthforuri(ui, bytes(parsed_url), user)
479
480
481
482
        # If it user-less version not work, let's try with added username to handle
        # both config conventions
        if (not res) and user:
            parsed_url.user = user
483
            res = readauthforuri(ui, bytes(parsed_url), user)
484
            parsed_url.user = None
485
486
487
488
489
        if res:
            group, auth_token = res
        else:
            auth_token = None

Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
490
        if auth_token:
491
492
493
            username = auth_token.get(b'username')
            password = auth_token.get(b'password')
            prefix = auth_token.get(b'prefix')
494
        else:
495
            username = user
496
497
            password = None
            prefix = None
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
498

499
        password_url = self.password_url(bytes(parsed_url), prefix)
500

Marcin Kasperski's avatar
Marcin Kasperski committed
501
502
503
504
        ui.debug(meu.ui_string("keyring: Password url: %s, user: %s, password: %s (prefix: %s)\n",
                               password_url, username,
                               b'********' if password else b'',
                               prefix))
505

506
        return username, password, password_url
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
507

508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
    def _note_last_reply(self, realm, authuri, user, req):
        """
        Internal helper. Saves info about auth-data obtained,
        preserves them in last_reply, and returns pair user, pwd
        """
        self.last_reply = dict(realm=realm, authuri=authuri,
                               user=user, req=req)

    def _after_bad_auth(self, ui, realm, authuri, req):
        """
        If we are called again just after identical previous
        request, then the previously returned auth must have been
        wrong. So we note this to force password prompt (and avoid
        reusing bad password indefinitely).

        This routine checks for this condition.
        """
        if self.last_reply:
            if (self.last_reply['realm'] == realm) \
               and (self.last_reply['authuri'] == authuri) \
               and (self.last_reply['req'] == req):
Marcin Kasperski's avatar
Marcin Kasperski committed
529
530
531
                ui.debug(meu.ui_string(
                    "keyring: Working after bad authentication, cached passwords not used %s\n",
                    str(self.last_reply)))
532
533
534
                return True
        return False

Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
535
    @staticmethod
536
    def password_url(base_url, prefix):
537
538
539
        """Calculates actual url identifying the password. Takes
        configured prefix under consideration (so can be shorter
        than repo url)"""
540
        if not prefix or prefix == b'*':
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
541
            return base_url
542
543
        scheme, hostpath = base_url.split(b'://', 1)
        p = prefix.split(b'://', 1)
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
544
545
546
547
        if len(p) > 1:
            prefix_host_path = p[1]
        else:
            prefix_host_path = prefix
548
        password_url = scheme + b'://' + prefix_host_path
549
        return password_url
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
550
551

    @staticmethod
552
    def unpack_url(authuri):
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
553
        """
554
        Takes original url for which authentication is attempted and:
555

556
557
558
559
        - Strips query params from url. Used to convert urls like
          https://repo.machine.com/repos/apps/module?pairs=0000000000000000000000000000000000000000-0000000000000000000000000000000000000000&cmd=between
          to
          https://repo.machine.com/repos/apps/module
560

561
562
        - Extracts username and password, if present, and removes them from url
          (so prefix matching works properly)
563
564

        Returns url, user, password
565
566
        where url is mercurial.util.url object already stripped of all those
        params.
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
567
        """
568
569
        # In case of py3, util.url expects bytes
        authuri = meu.pycompat.bytestr(authuri)
Marcin Kasperski's avatar
Marcin Kasperski committed
570

571
572
        # mercurial.util.url, rather handy url parser
        parsed_url = util.url(authuri)
573
        parsed_url.query = b''
574
        parsed_url.fragment = None
575
576
577
        # Strip arguments to get actual remote repository url.
        # base_url = "%s://%s%s" % (parsed_url.scheme, parsed_url.netloc,
        #                       parsed_url.path)
578
579
580
581
        user = parsed_url.user
        passwd = parsed_url.passwd
        parsed_url.user = None
        parsed_url.passwd = None
582

583
        return parsed_url, user, passwd
584

Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
585
586

############################################################
587
# Mercurial monkey-patching
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
588
589
590
591
592
593
594
595
596
597
598
599
############################################################


@monkeypatch_method(passwordmgr)
def find_user_password(self, realm, authuri):
    """
    keyring-based implementation of username/password query
    for HTTP(S) connections

    Passwords are saved in gnome keyring, OSX/Chain or other platform
    specific storage and keyed by the repository url
    """
Marcin Kasperski's avatar
Marcin Kasperski committed
600
601
602
603
    # In sync with hg 5.0
    assert isinstance(realm, (type(None), str))
    assert isinstance(authuri, str)

Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
604
605
606
607
608
609
610
611
612
613
614
615
    # Extend object attributes
    if not hasattr(self, '_pwd_handler'):
        self._pwd_handler = HTTPPasswordHandler()

    if hasattr(self, '_http_req'):
        req = self._http_req
    else:
        req = None

    return self._pwd_handler.find_auth(self, realm, authuri, req)


616
@monkeypatch_method(AbstractBasicAuthHandler, "http_error_auth_reqed")
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
617
def basic_http_error_auth_reqed(self, authreq, host, req, headers):
618
619
    """Preserves current HTTP request so it can be consulted
    in find_user_password above"""
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
620
621
622
623
624
625
626
    self.passwd._http_req = req
    try:
        return basic_http_error_auth_reqed.orig(self, authreq, host, req, headers)
    finally:
        self.passwd._http_req = None


627
@monkeypatch_method(AbstractDigestAuthHandler, "http_error_auth_reqed")
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
628
def digest_http_error_auth_reqed(self, authreq, host, req, headers):
629
630
    """Preserves current HTTP request so it can be consulted
    in find_user_password above"""
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
    self.passwd._http_req = req
    try:
        return digest_http_error_auth_reqed.orig(self, authreq, host, req, headers)
    finally:
        self.passwd._http_req = None

############################################################
# SMTP support
############################################################


def try_smtp_login(ui, smtp_obj, username, password):
    """
    Attempts smtp login on smtp_obj (smtplib.SMTP) using username and
    password.

    Returns:
    - True if login succeeded
    - False if login failed due to the wrong credentials

    Throws Abort exception if login failed for any other reason.

    Immediately returns False if password is empty
    """
    if not password:
        return False
    try:
658
        ui.note(_('(authenticating to mail server as %s)\n') %
659
                username)
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
660
661
        smtp_obj.login(username, password)
        return True
662
    except smtplib.SMTPException as inst:
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
663
        if inst.smtp_code == 535:
Marcin Kasperski's avatar
Marcin Kasperski committed
664
665
            ui.status(meu.ui_string("SMTP login failed: %s\n\n",
                                    inst.smtp_error))
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
666
667
            return False
        else:
Marcin Kasperski's avatar
Marcin Kasperski committed
668
            raise error.Abort(inst)
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
669
670
671
672


def keyring_supported_smtp(ui, username):
    """
673
674
675
    keyring-integrated replacement for mercurial.mail._smtp Used only
    when configuration file contains username, but does not contain
    the password.
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
676
677

    Most of the routine below is copied as-is from
678
679
680
    mercurial.mail._smtp.  The critical changed part is marked with #
    >>>>> and # <<<<< markers, there are also some fixes which make
    the code working on various Mercurials (like parsebool import).
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
681
    """
682
683
684
685
686
    try:
        from mercurial.utils.stringutil import parsebool
    except ImportError:
        from mercurial.utils import parsebool

Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
687
688
689
    local_hostname = ui.config('smtp', 'local_hostname')
    tls = ui.config('smtp', 'tls', 'none')
    # backward compatible: when tls = true, we use starttls.
690
    starttls = tls == 'starttls' or parsebool(tls)
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
691
692
    smtps = tls == 'smtps'
    if (starttls or smtps) and not util.safehasattr(socket, 'ssl'):
Marcin Kasperski's avatar
Marcin Kasperski committed
693
        raise error.Abort(_("can't use TLS: Python SSL support not installed"))
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
694
695
    mailhost = ui.config('smtp', 'host')
    if not mailhost:
Marcin Kasperski's avatar
Marcin Kasperski committed
696
        raise error.Abort(_('smtp.host not configured - cannot send mail'))
697
698
    if getattr(sslutil, 'sslkwargs', None) is None:
        sslkwargs = None
699
    elif starttls or smtps:
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
700
701
702
703
704
        sslkwargs = sslutil.sslkwargs(ui, mailhost)
    else:
        sslkwargs = {}
    if smtps:
        ui.note(_('(using smtps)\n'))
705
706

        # mercurial 3.8 added a mandatory host arg
707
708
709
        if not sslkwargs:
            s = SMTPS(ui, local_hostname=local_hostname, host=mailhost)
        elif 'host' in SMTPS.__init__.__code__.co_varnames:
710
711
712
            s = SMTPS(sslkwargs, local_hostname=local_hostname, host=mailhost)
        else:
            s = SMTPS(sslkwargs, local_hostname=local_hostname)
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
713
    elif starttls:
714
715
716
        if not sslkwargs:
            s = STARTTLS(ui, local_hostname=local_hostname, host=mailhost)
        elif 'host' in STARTTLS.__init__.__code__.co_varnames:
717
718
719
            s = STARTTLS(sslkwargs, local_hostname=local_hostname, host=mailhost)
        else:
            s = STARTTLS(sslkwargs, local_hostname=local_hostname)
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
720
721
722
723
724
725
726
    else:
        s = smtplib.SMTP(local_hostname=local_hostname)
    if smtps:
        defaultport = 465
    else:
        defaultport = 25
    mailport = util.getport(ui.config('smtp', 'port', defaultport))
727
728
    ui.note(_('sending mail: smtp host %s, port %s\n') %
            (mailhost, mailport))
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
729
730
731
732
733
734
    s.connect(host=mailhost, port=mailport)
    if starttls:
        ui.note(_('(using starttls)\n'))
        s.ehlo()
        s.starttls()
        s.ehlo()
735
    if starttls or smtps:
736
        if getattr(sslutil, 'validatesocket', None):
737
            ui.note(_('(verifying remote certificate)\n'))
738
            sslutil.validatesocket(s.sock)
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755

    # >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
    stored = password = password_store.get_smtp_password(
        mailhost, mailport, username)
    # No need to check whether password was found as try_smtp_login
    # just returns False if it is absent.
    while not try_smtp_login(ui, s, username, password):
        password = ui.getpass(_("Password for %s on %s:%d: ") % (username, mailhost, mailport))

    if stored != password:
        password_store.set_smtp_password(
            mailhost, mailport, username, password)
    # <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<

    def send(sender, recipients, msg):
        try:
            return s.sendmail(sender, recipients, msg)
756
        except smtplib.SMTPRecipientsRefused as inst:
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
757
            recipients = [r[1] for r in inst.recipients.values()]
Marcin Kasperski's avatar
Marcin Kasperski committed
758
            raise error.Abort('\n' + '\n'.join(recipients))
759
        except smtplib.SMTPException as inst:
Marcin Kasperski's avatar
Marcin Kasperski committed
760
            raise error.Abort(inst)
Marcin Kasperski's avatar
eol    
Marcin Kasperski committed
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784

    return send

############################################################
# SMTP monkeypatching
############################################################


@monkeypatch_method(mail)
def _smtp(ui):
    """
    build an smtp connection and return a function to send email

    This is the monkeypatched version of _smtp(ui) function from
    mercurial/mail.py. It calls the original unless username
    without password is given in the configuration.
    """
    username = ui.config('smtp', 'username')
    password = ui.config('smtp', 'password')

    if username and not password:
        return keyring_supported_smtp(ui, username)
    else:
        return _smtp.orig(ui)
785
786
787
788
789


############################################################
# Custom commands
############################################################
790

791
792
cmdtable = {}
command = meu.command(cmdtable)
Marcin Kasperski's avatar
Marcin Kasperski committed
793
794


795
@command(b'keyring_check',
796
         [],
797
798
         _("keyring_check [PATH]"),
         optionalrepo=True)
Marcin Kasperski's avatar
Marcin Kasperski committed
799
def cmd_keyring_check(ui, repo, *path_args, **opts):   # pylint: disable=unused-argument
800
    """
801
    Prints basic info (whether password is currently saved, and how is
802
803
804
805
    it identified) for given path.

    Can be run without parameters to show status for all (current repository) paths which
    are HTTP-like.
806
    """
807
    defined_paths = [(name, url)
808
                     for name, url in ui.configitems(b'paths')]
809
    if path_args:
810
811
        # Maybe parameter is an alias
        defined_paths_dic = dict(defined_paths)
812
813
        paths = [(path_arg, defined_paths_dic.get(path_arg, path_arg))
                 for path_arg in path_args]
Marcin Kasperski's avatar
notes    
Marcin Kasperski committed
814
    else:
815
        if not repo:
Marcin Kasperski's avatar
Marcin Kasperski committed
816
            ui.status(meu.ui_string("Url to check not specified. Either run ``hg keyring_check https://...``, or run the command inside some repository (to test all defined paths).\n"))
817
            return
818
        paths = [(name, url) for name, url in defined_paths]
819
820

    if not paths:
Marcin Kasperski's avatar
Marcin Kasperski committed
821
        ui.status(meu.ui_string("keyring_check: no paths defined\n"))
822
        return
823
824
825

    handler = HTTPPasswordHandler()

Marcin Kasperski's avatar
Marcin Kasperski committed
826
    ui.status(meu.ui_string("keyring password save status:\n"))
827
    for name, url in paths:
Marcin Kasperski's avatar
Marcin Kasperski committed
828
        if not is_http_path(url):
829
            if path_args:
Marcin Kasperski's avatar
Marcin Kasperski committed
830
831
                ui.status(meu.ui_string("    %s: non-http path (%s)\n",
                                        name, url))
832
            continue
Marcin Kasperski's avatar
Marcin Kasperski committed
833
        user, pwd, source, final_url = handler.get_credentials(
834
            make_passwordmgr(ui), name, url)
835
        if pwd:
Marcin Kasperski's avatar
Marcin Kasperski committed
836
837
838
            ui.status(meu.ui_string(
                "    %s: password available, source: %s, bound to user %s, url %s\n",
                name, source, user, final_url))
839
        elif user:
Marcin Kasperski's avatar
Marcin Kasperski committed
840
841
842
            ui.status(meu.ui_string(
                "    %s: password not available, once entered, will be bound to user %s, url %s\n",
                name, user, final_url))
843
        else:
Marcin Kasperski's avatar
Marcin Kasperski committed
844
845
846
            ui.status(meu.ui_string(
                "    %s: password not available, user unknown, url %s\n",
                name, final_url))
847
848


849
@command(b'keyring_clear',
850
         [],
851
852
         _('hg keyring_clear PATH-OR-ALIAS'),
         optionalrepo=True)
Marcin Kasperski's avatar
Marcin Kasperski committed
853
def cmd_keyring_clear(ui, repo, path, **opts):  # pylint: disable=unused-argument
854
    """
Marcin Kasperski's avatar
Marcin Kasperski committed
855
    Drops password bound to given path (if any is saved).
856
857
858

    Parameter can be given as full url (``https://John@bitbucket.org``) or as the name
    of path alias (``bitbucket``).
859
    """
Marcin Kasperski's avatar
Marcin Kasperski committed
860
    path_url = path
861
    for name, url in ui.configitems(b'paths'):
Marcin Kasperski's avatar
Marcin Kasperski committed
862
863
864
865
        if name == path:
            path_url = url
            break
    if not is_http_path(path_url):
Marcin Kasperski's avatar
Marcin Kasperski committed
866
867
868
        ui.status(meu.ui_string(
            "%s is not a http path (and %s can't be resolved as path alias)\n",
            path, path_url))
Marcin Kasperski's avatar
Marcin Kasperski committed
869
870
871
872
873
        return

    handler = HTTPPasswordHandler()

    user, pwd, source, final_url = handler.get_credentials(
874
        make_passwordmgr(ui), path, path_url)
Marcin Kasperski's avatar
Marcin Kasperski committed
875
    if not user:
Marcin Kasperski's avatar
Marcin Kasperski committed
876
877
        ui.status(meu.ui_string("Username not configured for url %s\n",
                                final_url))
Marcin Kasperski's avatar
Marcin Kasperski committed
878
879
        return
    if not pwd:
Marcin Kasperski's avatar
Marcin Kasperski committed
880
881
        ui.status(meu.ui_string("No password is saved for user %s, url %s\n",
                                user, final_url))
Marcin Kasperski's avatar
Marcin Kasperski committed
882
883
884
        return

    if source != handler.SRC_KEYRING:
Marcin Kasperski's avatar
Marcin Kasperski committed
885
886
        ui.status(meu.ui_string("Password for user %s, url %s is saved in %s, not in keyring\n",
                                user, final_url, source))
Marcin Kasperski's avatar
Marcin Kasperski committed
887
888

    password_store.clear_http_password(final_url, user)
Marcin Kasperski's avatar
Marcin Kasperski committed
889
890
    ui.status(meu.ui_string("Password removed for user %s, url %s\n",
                            user, final_url))
Marcin Kasperski's avatar
Marcin Kasperski committed
891
892


893
buglink = 'https://foss.heptapod.net/mercurial/mercurial_keyring/issues'