mercurial_keyring.py
887 lines
| 32.5 KiB
| text/x-python
|
PythonLexer
Marcin Kasperski
|
r175 | # -*- 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. | ||||
# | ||||
Marcin Kasperski
|
r282 | # See README.rst for more details. | ||
Marcin Kasperski
|
r175 | |||
Marcin Kasperski
|
r178 | '''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
|
r175 | ''' | ||
Marcin Kasperski
|
r225 | import socket | ||
import os | ||||
import sys | ||||
import re | ||||
Marcin Kasperski
|
r269 | if sys.version_info[0] < 3: | ||
from urllib2 import ( | ||||
HTTPPasswordMgrWithDefaultRealm, AbstractBasicAuthHandler, AbstractDigestAuthHandler) | ||||
else: | ||||
from urllib.request import ( | ||||
HTTPPasswordMgrWithDefaultRealm, AbstractBasicAuthHandler, AbstractDigestAuthHandler) | ||||
import smtplib | ||||
Marcin Kasperski
|
r225 | |||
Marcin Kasperski
|
r250 | from mercurial import util, sslutil, error | ||
Marcin Kasperski
|
r175 | from mercurial.i18n import _ | ||
Marcin Kasperski
|
r176 | from mercurial.url import passwordmgr | ||
Marcin Kasperski
|
r175 | from mercurial import mail | ||
from mercurial.mail import SMTPS, STARTTLS | ||||
from mercurial import encoding | ||||
Marcin Kasperski
|
r258 | from mercurial import ui as uimod | ||
Marcin Kasperski
|
r175 | |||
Marcin Kasperski
|
r181 | # pylint: disable=invalid-name, line-too-long, protected-access, too-many-arguments | ||
Marcin Kasperski
|
r175 | |||
########################################################################### | ||||
# 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
|
r250 | raise error.Abort(_("""Can not import mercurial_extension_utils. | ||
Marcin Kasperski
|
r175 | Please install this module in Python path. | ||
Marcin Kasperski
|
r283 | See Installation chapter in https://foss.heptapod.net/mercurial/mercurial_keyring/ for details | ||
Marcin Kasperski
|
r175 | (and for info about TortoiseHG on Windows, or other bundled Python).""")) | ||
return mercurial_extension_utils | ||||
Marcin Kasperski
|
r256 | |||
Marcin Kasperski
|
r175 | 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. | ||||
Marcin Kasperski
|
r200 | mod, was_imported_now = meu.direct_import_ext( | ||
"keyring", [ | ||||
Marcin Kasperski
|
r175 | "gobject._gobject", | ||
"configparser", | ||||
"json", | ||||
"abc", | ||||
"io", | ||||
"keyring", | ||||
"gdata.docs.service", | ||||
"gdata.service", | ||||
"types", | ||||
"atom.http", | ||||
"atom.http_interface", | ||||
"atom.service", | ||||
"atom.token_store", | ||||
"ctypes", | ||||
"secretstorage.exceptions", | ||||
"fs.opener", | ||||
Marcin Kasperski
|
r268 | "win32ctypes.pywin32", | ||
"win32ctypes.pywin32.pywintypes", | ||||
"win32ctypes.pywin32.win32cred", | ||||
"pywintypes", | ||||
"win32cred", | ||||
Marcin Kasperski
|
r200 | ]) | ||
if was_imported_now: | ||||
# Shut up warning about uninitialized logging by keyring | ||||
meu.disable_logging("keyring") | ||||
return mod | ||||
Marcin Kasperski
|
r175 | |||
Marcin Kasperski
|
r256 | |||
Marcin Kasperski
|
r175 | ################################################################# | ||
# 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""" | ||||
Marcin Kasperski
|
r276 | self.set_http_password(url, username, b"") | ||
Marcin Kasperski
|
r175 | |||
@staticmethod | ||||
def _format_http_key(url, username): | ||||
"""Construct actual key for password identification""" | ||||
Marcin Kasperski
|
r276 | # 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
|
r175 | |||
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() | ||||
Marcin Kasperski
|
r258 | try: | ||
password = keyring.get_password(KEYRING_SERVICE, pwdkey) | ||||
except Exception as err: | ||||
ui = uimod.ui() | ||||
Marcin Kasperski
|
r276 | 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
|
r175 | # Reverse recoding from next routine | ||
Marcin Kasperski
|
r272 | if isinstance(password, meu.pycompat.unicode): | ||
Marcin Kasperski
|
r175 | 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
|
r277 | # 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') | ||||
Marcin Kasperski
|
r258 | try: | ||
keyring.set_password( | ||||
KEYRING_SERVICE, pwdkey, password) | ||||
except Exception as err: | ||||
ui = uimod.ui() | ||||
Marcin Kasperski
|
r274 | ui.warn(meu.ui_string( | ||
"keyring: keyring backend doesn't seem to work, password was not saved. Error details: %s\n", | ||||
err)) | ||||
Marcin Kasperski
|
r175 | |||
Marcin Kasperski
|
r256 | |||
Marcin Kasperski
|
r175 | password_store = PasswordStore() | ||
############################################################ | ||||
Marcin Kasperski
|
r184 | # Various utils | ||
Marcin Kasperski
|
r175 | ############################################################ | ||
Marcin Kasperski
|
r184 | 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) | ||||
Marcin Kasperski
|
r275 | _re_http_url = re.compile(b'^https?://') | ||
Marcin Kasperski
|
r206 | |||
Marcin Kasperski
|
r256 | |||
Marcin Kasperski
|
r206 | def is_http_path(url): | ||
return bool(_re_http_url.search(url)) | ||||
Marcin Kasperski
|
r240 | |||
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
|
r175 | ############################################################ | ||
Marcin Kasperski
|
r184 | # HTTP password management | ||
Marcin Kasperski
|
r175 | ############################################################ | ||
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): | ||||
Marcin Kasperski
|
r184 | self.pwd_cache = PwdCache() | ||
Marcin Kasperski
|
r175 | self.last_reply = None | ||
Marcin Kasperski
|
r185 | # Markers and also names used in debug notes. Password source | ||
Marcin Kasperski
|
r184 | SRC_URL = "repository URL" | ||
SRC_CFGAUTH = "hgrc" | ||||
SRC_MEMCACHE = "temporary cache" | ||||
Marcin Kasperski
|
r196 | SRC_URLCACHE = "urllib temporary cache" | ||
Marcin Kasperski
|
r184 | SRC_KEYRING = "keyring" | ||
Marcin Kasperski
|
r185 | def get_credentials(self, pwmgr, realm, authuri, skip_caches=False): | ||
Marcin Kasperski
|
r184 | """ | ||
Looks up for user credentials in various places, returns them | ||||
and information about their source. | ||||
Marcin Kasperski
|
r185 | 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). | ||||
Marcin Kasperski
|
r184 | |||
Returns: user, password, SRC_*, actual_url | ||||
Marcin Kasperski
|
r185 | |||
If not found, password and SRC is None, user can be given or | ||||
not, url is always set | ||||
Marcin Kasperski
|
r184 | """ | ||
ui = pwmgr.ui | ||||
Marcin Kasperski
|
r199 | parsed_url, url_user, url_passwd = self.unpack_url(authuri) | ||
Marcin Kasperski
|
r272 | base_url = bytes(parsed_url) | ||
Marcin Kasperski
|
r274 | 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'')) | ||||
Marcin Kasperski
|
r198 | |||
Marcin Kasperski
|
r196 | # Extract username (or password) stored directly in url | ||
if url_user and url_passwd: | ||||
return url_user, url_passwd, self.SRC_URL, base_url | ||||
Marcin Kasperski
|
r184 | |||
Marcin Kasperski
|
r196 | # Extract data from urllib (in case it was already stored) | ||
Marcin Kasperski
|
r269 | if isinstance(pwmgr, HTTPPasswordMgrWithDefaultRealm): | ||
Henrik Stuart
|
r232 | urllib_user, urllib_pwd = \ | ||
Marcin Kasperski
|
r269 | HTTPPasswordMgrWithDefaultRealm.find_user_password( | ||
Henrik Stuart
|
r232 | pwmgr, realm, authuri) | ||
else: | ||||
urllib_user, urllib_pwd = pwmgr.passwddb.find_user_password( | ||||
realm, authuri) | ||||
Marcin Kasperski
|
r196 | if urllib_user and urllib_pwd: | ||
return urllib_user, urllib_pwd, self.SRC_URLCACHE, base_url | ||||
Marcin Kasperski
|
r184 | |||
Marcin Kasperski
|
r212 | actual_user = url_user or urllib_user | ||
Marcin Kasperski
|
r184 | # Consult configuration to normalize url to prefix, and find username | ||
# (and maybe password) | ||||
auth_user, auth_pwd, keyring_url = self.get_url_config( | ||||
Marcin Kasperski
|
r212 | ui, parsed_url, actual_user) | ||
if auth_user and actual_user and (actual_user != auth_user): | ||||
Marcin Kasperski
|
r250 | 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))) | ||
Marcin Kasperski
|
r184 | if auth_user and auth_pwd: | ||
return auth_user, auth_pwd, self.SRC_CFGAUTH, keyring_url | ||||
Marcin Kasperski
|
r212 | actual_user = actual_user or auth_user | ||
Marcin Kasperski
|
r185 | if skip_caches: | ||
Marcin Kasperski
|
r212 | return actual_user, None, None, keyring_url | ||
Marcin Kasperski
|
r185 | |||
Marcin Kasperski
|
r184 | # Check memory cache (reuse ) | ||
# Checking the memory cache (there may be many http calls per command) | ||||
Marcin Kasperski
|
r212 | cached_pwd = self.pwd_cache.check(realm, keyring_url, actual_user) | ||
Marcin Kasperski
|
r184 | if cached_pwd: | ||
Marcin Kasperski
|
r212 | return actual_user, cached_pwd, self.SRC_MEMCACHE, keyring_url | ||
Marcin Kasperski
|
r184 | |||
# Load from keyring. | ||||
Marcin Kasperski
|
r212 | if actual_user: | ||
Marcin Kasperski
|
r274 | ui.debug(meu.ui_string("keyring: looking for password (user %s, url %s)\n", | ||
actual_user, keyring_url)) | ||||
Marcin Kasperski
|
r212 | keyring_pwd = password_store.get_http_password(keyring_url, actual_user) | ||
Marcin Kasperski
|
r184 | if keyring_pwd: | ||
Marcin Kasperski
|
r212 | return actual_user, keyring_pwd, self.SRC_KEYRING, keyring_url | ||
Marcin Kasperski
|
r184 | |||
Marcin Kasperski
|
r212 | return actual_user, None, None, keyring_url | ||
Marcin Kasperski
|
r184 | |||
Marcin Kasperski
|
r185 | @staticmethod | ||
def prompt_interactively(ui, user, realm, url): | ||||
"""Actual interactive prompt""" | ||||
Marcin Kasperski
|
r175 | if not ui.interactive(): | ||
Marcin Kasperski
|
r250 | raise error.Abort(_('keyring: http authorization required but program used in non-interactive mode')) | ||
Marcin Kasperski
|
r175 | |||
Marcin Kasperski
|
r184 | if not user: | ||
Marcin Kasperski
|
r274 | ui.status(meu.ui_string("keyring: username not specified in hgrc (or in url). Password will not be saved.\n")) | ||
Marcin Kasperski
|
r175 | |||
Marcin Kasperski
|
r274 | 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)) | ||||
Marcin Kasperski
|
r184 | if user: | ||
Marcin Kasperski
|
r274 | ui.write(meu.ui_string("user: %s (fixed in hgrc or url)\n", | ||
user)) | ||||
Marcin Kasperski
|
r175 | else: | ||
Marcin Kasperski
|
r274 | user = ui.prompt(meu.ui_string("user:"), | ||
default=None) | ||||
pwd = ui.getpass(meu.ui_string("password: ")) | ||||
Marcin Kasperski
|
r185 | 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
|
r274 | ui.debug(meu.ui_string("keyring: Password found in %s\n", | ||
src)) | ||||
Marcin Kasperski
|
r185 | return user, pwd | ||
# Last resort: interactive prompt | ||||
user, pwd = self.prompt_interactively(ui, user, realm, final_url) | ||||
Marcin Kasperski
|
r175 | |||
Marcin Kasperski
|
r184 | if user: | ||
Marcin Kasperski
|
r175 | # 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
|
r274 | ui.debug(meu.ui_string("keyring: Saving password for %s to keyring\n", | ||
user)) | ||||
Dan Villiom Podlaski Christiansen
|
r227 | try: | ||
password_store.set_http_password(final_url, user, pwd) | ||||
Marcin Kasperski
|
r268 | except Exception as e: | ||
ataumoefolau
|
r236 | keyring = import_keyring() | ||
if isinstance(e, keyring.errors.PasswordSetError): | ||||
ui.traceback() | ||||
Marcin Kasperski
|
r274 | ui.warn(meu.ui_string("warning: failed to save password in keyring\n")) | ||
ataumoefolau
|
r236 | else: | ||
raise e | ||||
Marcin Kasperski
|
r175 | |||
# Saving password to the memory cache | ||||
Marcin Kasperski
|
r184 | self.pwd_cache.store(realm, final_url, user, pwd) | ||
Marcin Kasperski
|
r185 | self._note_last_reply(realm, authuri, user, req) | ||
Marcin Kasperski
|
r274 | ui.debug(meu.ui_string("keyring: Manually entered password\n")) | ||
Marcin Kasperski
|
r185 | return user, pwd | ||
Marcin Kasperski
|
r175 | |||
Marcin Kasperski
|
r199 | def get_url_config(self, ui, parsed_url, user): | ||
Marcin Kasperski
|
r175 | """ | ||
Marcin Kasperski
|
r184 | Checks configuration to decide whether/which username, prefix, | ||
and password are configured for given url. Consults [auth] section. | ||||
Marcin Kasperski
|
r175 | |||
Marcin Kasperski
|
r184 | 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
|
r181 | """ | ||
Marcin Kasperski
|
r178 | from mercurial.httpconnection import readauthforuri | ||
Marcin Kasperski
|
r274 | ui.debug(meu.ui_string("keyring: checking for hgrc info about url %s, user %s\n", | ||
parsed_url, user)) | ||||
Marcin Kasperski
|
r275 | |||
res = readauthforuri(ui, bytes(parsed_url), user) | ||||
Marcin Kasperski
|
r199 | # 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 | ||||
Marcin Kasperski
|
r275 | res = readauthforuri(ui, bytes(parsed_url), user) | ||
Marcin Kasperski
|
r199 | parsed_url.user = None | ||
Marcin Kasperski
|
r178 | if res: | ||
group, auth_token = res | ||||
else: | ||||
auth_token = None | ||||
Marcin Kasperski
|
r175 | if auth_token: | ||
Marcin Kasperski
|
r275 | username = auth_token.get(b'username') | ||
password = auth_token.get(b'password') | ||||
prefix = auth_token.get(b'prefix') | ||||
Marcin Kasperski
|
r184 | else: | ||
Marcin Kasperski
|
r197 | username = user | ||
Marcin Kasperski
|
r184 | password = None | ||
prefix = None | ||||
Marcin Kasperski
|
r175 | |||
Marcin Kasperski
|
r275 | password_url = self.password_url(bytes(parsed_url), prefix) | ||
Marcin Kasperski
|
r197 | |||
Marcin Kasperski
|
r274 | 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)) | ||||
Marcin Kasperski
|
r197 | |||
Marcin Kasperski
|
r184 | return username, password, password_url | ||
Marcin Kasperski
|
r175 | |||
Marcin Kasperski
|
r185 | 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
|
r274 | ui.debug(meu.ui_string( | ||
"keyring: Working after bad authentication, cached passwords not used %s\n", | ||||
str(self.last_reply))) | ||||
Marcin Kasperski
|
r185 | return True | ||
return False | ||||
Marcin Kasperski
|
r175 | @staticmethod | ||
Marcin Kasperski
|
r183 | def password_url(base_url, prefix): | ||
Marcin Kasperski
|
r185 | """Calculates actual url identifying the password. Takes | ||
configured prefix under consideration (so can be shorter | ||||
than repo url)""" | ||||
Marcin Kasperski
|
r275 | if not prefix or prefix == b'*': | ||
Marcin Kasperski
|
r175 | return base_url | ||
Marcin Kasperski
|
r275 | scheme, hostpath = base_url.split(b'://', 1) | ||
p = prefix.split(b'://', 1) | ||||
Marcin Kasperski
|
r175 | if len(p) > 1: | ||
prefix_host_path = p[1] | ||||
else: | ||||
prefix_host_path = prefix | ||||
Marcin Kasperski
|
r275 | password_url = scheme + b'://' + prefix_host_path | ||
Marcin Kasperski
|
r183 | return password_url | ||
Marcin Kasperski
|
r175 | |||
@staticmethod | ||||
Marcin Kasperski
|
r196 | def unpack_url(authuri): | ||
Marcin Kasperski
|
r175 | """ | ||
Marcin Kasperski
|
r198 | Takes original url for which authentication is attempted and: | ||
Marcin Kasperski
|
r196 | |||
Marcin Kasperski
|
r198 | - 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 | ||||
Marcin Kasperski
|
r196 | |||
Marcin Kasperski
|
r198 | - Extracts username and password, if present, and removes them from url | ||
(so prefix matching works properly) | ||||
Marcin Kasperski
|
r196 | |||
Returns url, user, password | ||||
Marcin Kasperski
|
r199 | where url is mercurial.util.url object already stripped of all those | ||
params. | ||||
Marcin Kasperski
|
r175 | """ | ||
Marcin Kasperski
|
r272 | # In case of py3, util.url expects bytes | ||
authuri = meu.pycompat.bytestr(authuri) | ||||
Marcin Kasperski
|
r271 | |||
Marcin Kasperski
|
r196 | # mercurial.util.url, rather handy url parser | ||
parsed_url = util.url(authuri) | ||||
Marcin Kasperski
|
r272 | parsed_url.query = b'' | ||
Marcin Kasperski
|
r197 | parsed_url.fragment = None | ||
Marcin Kasperski
|
r196 | # Strip arguments to get actual remote repository url. | ||
# base_url = "%s://%s%s" % (parsed_url.scheme, parsed_url.netloc, | ||||
# parsed_url.path) | ||||
Marcin Kasperski
|
r198 | user = parsed_url.user | ||
passwd = parsed_url.passwd | ||||
parsed_url.user = None | ||||
parsed_url.passwd = None | ||||
Marcin Kasperski
|
r196 | |||
Marcin Kasperski
|
r199 | return parsed_url, user, passwd | ||
Marcin Kasperski
|
r196 | |||
Marcin Kasperski
|
r175 | |||
############################################################ | ||||
Marcin Kasperski
|
r184 | # Mercurial monkey-patching | ||
Marcin Kasperski
|
r175 | ############################################################ | ||
@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
|
r271 | # In sync with hg 5.0 | ||
assert isinstance(realm, (type(None), str)) | ||||
assert isinstance(authuri, str) | ||||
Marcin Kasperski
|
r175 | # 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) | ||||
Marcin Kasperski
|
r269 | @monkeypatch_method(AbstractBasicAuthHandler, "http_error_auth_reqed") | ||
Marcin Kasperski
|
r175 | def basic_http_error_auth_reqed(self, authreq, host, req, headers): | ||
Marcin Kasperski
|
r178 | """Preserves current HTTP request so it can be consulted | ||
in find_user_password above""" | ||||
Marcin Kasperski
|
r175 | self.passwd._http_req = req | ||
try: | ||||
return basic_http_error_auth_reqed.orig(self, authreq, host, req, headers) | ||||
finally: | ||||
self.passwd._http_req = None | ||||
Marcin Kasperski
|
r269 | @monkeypatch_method(AbstractDigestAuthHandler, "http_error_auth_reqed") | ||
Marcin Kasperski
|
r175 | def digest_http_error_auth_reqed(self, authreq, host, req, headers): | ||
Marcin Kasperski
|
r178 | """Preserves current HTTP request so it can be consulted | ||
in find_user_password above""" | ||||
Marcin Kasperski
|
r175 | 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: | ||||
Marcin Kasperski
|
r270 | ui.note(_('(authenticating to mail server as %s)\n') % | ||
Marcin Kasperski
|
r269 | username) | ||
Marcin Kasperski
|
r175 | smtp_obj.login(username, password) | ||
return True | ||||
Marcin Kasperski
|
r268 | except smtplib.SMTPException as inst: | ||
Marcin Kasperski
|
r175 | if inst.smtp_code == 535: | ||
Marcin Kasperski
|
r274 | ui.status(meu.ui_string("SMTP login failed: %s\n\n", | ||
inst.smtp_error)) | ||||
Marcin Kasperski
|
r175 | return False | ||
else: | ||||
Marcin Kasperski
|
r250 | raise error.Abort(inst) | ||
Marcin Kasperski
|
r175 | |||
def keyring_supported_smtp(ui, username): | ||||
""" | ||||
Marcin Kasperski
|
r257 | keyring-integrated replacement for mercurial.mail._smtp Used only | ||
when configuration file contains username, but does not contain | ||||
the password. | ||||
Marcin Kasperski
|
r175 | |||
Most of the routine below is copied as-is from | ||||
Marcin Kasperski
|
r257 | 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
|
r175 | """ | ||
Marcin Kasperski
|
r257 | try: | ||
from mercurial.utils.stringutil import parsebool | ||||
except ImportError: | ||||
from mercurial.utils import parsebool | ||||
Marcin Kasperski
|
r175 | local_hostname = ui.config('smtp', 'local_hostname') | ||
tls = ui.config('smtp', 'tls', 'none') | ||||
# backward compatible: when tls = true, we use starttls. | ||||
Marcin Kasperski
|
r257 | starttls = tls == 'starttls' or parsebool(tls) | ||
Marcin Kasperski
|
r175 | smtps = tls == 'smtps' | ||
if (starttls or smtps) and not util.safehasattr(socket, 'ssl'): | ||||
Marcin Kasperski
|
r250 | raise error.Abort(_("can't use TLS: Python SSL support not installed")) | ||
Marcin Kasperski
|
r175 | mailhost = ui.config('smtp', 'host') | ||
if not mailhost: | ||||
Marcin Kasperski
|
r250 | raise error.Abort(_('smtp.host not configured - cannot send mail')) | ||
Dan Villiom Podlaski Christiansen
|
r228 | if getattr(sslutil, 'sslkwargs', None) is None: | ||
sslkwargs = None | ||||
Marcin Kasperski
|
r257 | elif starttls or smtps: | ||
Marcin Kasperski
|
r175 | sslkwargs = sslutil.sslkwargs(ui, mailhost) | ||
else: | ||||
sslkwargs = {} | ||||
if smtps: | ||||
ui.note(_('(using smtps)\n')) | ||||
Sean Farley
|
r221 | |||
# mercurial 3.8 added a mandatory host arg | ||||
Dan Villiom Podlaski Christiansen
|
r228 | if not sslkwargs: | ||
s = SMTPS(ui, local_hostname=local_hostname, host=mailhost) | ||||
elif 'host' in SMTPS.__init__.__code__.co_varnames: | ||||
Sean Farley
|
r221 | s = SMTPS(sslkwargs, local_hostname=local_hostname, host=mailhost) | ||
else: | ||||
s = SMTPS(sslkwargs, local_hostname=local_hostname) | ||||
Marcin Kasperski
|
r175 | elif starttls: | ||
Dan Villiom Podlaski Christiansen
|
r228 | if not sslkwargs: | ||
s = STARTTLS(ui, local_hostname=local_hostname, host=mailhost) | ||||
elif 'host' in STARTTLS.__init__.__code__.co_varnames: | ||||
Sean Farley
|
r221 | s = STARTTLS(sslkwargs, local_hostname=local_hostname, host=mailhost) | ||
else: | ||||
s = STARTTLS(sslkwargs, local_hostname=local_hostname) | ||||
Marcin Kasperski
|
r175 | else: | ||
s = smtplib.SMTP(local_hostname=local_hostname) | ||||
if smtps: | ||||
defaultport = 465 | ||||
else: | ||||
defaultport = 25 | ||||
mailport = util.getport(ui.config('smtp', 'port', defaultport)) | ||||
Marcin Kasperski
|
r270 | ui.note(_('sending mail: smtp host %s, port %s\n') % | ||
(mailhost, mailport)) | ||||
Marcin Kasperski
|
r175 | s.connect(host=mailhost, port=mailport) | ||
if starttls: | ||||
ui.note(_('(using starttls)\n')) | ||||
s.ehlo() | ||||
s.starttls() | ||||
s.ehlo() | ||||
Marcin Kasperski
|
r257 | if starttls or smtps: | ||
Dan Villiom Podlaski Christiansen
|
r228 | if getattr(sslutil, 'validatesocket', None): | ||
Marcin Kasperski
|
r257 | ui.note(_('(verifying remote certificate)\n')) | ||
Dan Villiom Podlaski Christiansen
|
r228 | sslutil.validatesocket(s.sock) | ||
Marcin Kasperski
|
r175 | |||
# >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> | ||||
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) | ||||
Marcin Kasperski
|
r268 | except smtplib.SMTPRecipientsRefused as inst: | ||
Marcin Kasperski
|
r175 | recipients = [r[1] for r in inst.recipients.values()] | ||
Marcin Kasperski
|
r250 | raise error.Abort('\n' + '\n'.join(recipients)) | ||
Marcin Kasperski
|
r268 | except smtplib.SMTPException as inst: | ||
Marcin Kasperski
|
r250 | raise error.Abort(inst) | ||
Marcin Kasperski
|
r175 | |||
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) | ||||
Marcin Kasperski
|
r178 | |||
############################################################ | ||||
# Custom commands | ||||
############################################################ | ||||
Marcin Kasperski
|
r184 | |||
Marcin Kasperski
|
r206 | cmdtable = {} | ||
command = meu.command(cmdtable) | ||||
Marcin Kasperski
|
r191 | |||
Marcin Kasperski
|
r269 | @command(b'keyring_check', | ||
Romain DEP.
|
r205 | [], | ||
Marcin Kasperski
|
r217 | _("keyring_check [PATH]"), | ||
optionalrepo=True) | ||||
Marcin Kasperski
|
r191 | def cmd_keyring_check(ui, repo, *path_args, **opts): # pylint: disable=unused-argument | ||
Marcin Kasperski
|
r184 | """ | ||
Marcin Kasperski
|
r185 | Prints basic info (whether password is currently saved, and how is | ||
Marcin Kasperski
|
r217 | it identified) for given path. | ||
Can be run without parameters to show status for all (current repository) paths which | ||||
are HTTP-like. | ||||
Marcin Kasperski
|
r184 | """ | ||
Marcin Kasperski
|
r187 | defined_paths = [(name, url) | ||
Marcin Kasperski
|
r275 | for name, url in ui.configitems(b'paths')] | ||
Marcin Kasperski
|
r188 | if path_args: | ||
Marcin Kasperski
|
r187 | # Maybe parameter is an alias | ||
defined_paths_dic = dict(defined_paths) | ||||
Marcin Kasperski
|
r188 | paths = [(path_arg, defined_paths_dic.get(path_arg, path_arg)) | ||
for path_arg in path_args] | ||||
Marcin Kasperski
|
r186 | else: | ||
Marcin Kasperski
|
r217 | if not repo: | ||
Marcin Kasperski
|
r274 | 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")) | ||
Marcin Kasperski
|
r217 | return | ||
Marcin Kasperski
|
r188 | paths = [(name, url) for name, url in defined_paths] | ||
Marcin Kasperski
|
r187 | |||
if not paths: | ||||
Marcin Kasperski
|
r274 | ui.status(meu.ui_string("keyring_check: no paths defined\n")) | ||
Marcin Kasperski
|
r217 | return | ||
Marcin Kasperski
|
r187 | |||
handler = HTTPPasswordHandler() | ||||
Marcin Kasperski
|
r274 | ui.status(meu.ui_string("keyring password save status:\n")) | ||
Marcin Kasperski
|
r187 | for name, url in paths: | ||
Marcin Kasperski
|
r191 | if not is_http_path(url): | ||
Marcin Kasperski
|
r188 | if path_args: | ||
Marcin Kasperski
|
r274 | ui.status(meu.ui_string(" %s: non-http path (%s)\n", | ||
name, url)) | ||||
Marcin Kasperski
|
r188 | continue | ||
Marcin Kasperski
|
r191 | user, pwd, source, final_url = handler.get_credentials( | ||
Marcin Kasperski
|
r240 | make_passwordmgr(ui), name, url) | ||
Marcin Kasperski
|
r187 | if pwd: | ||
Marcin Kasperski
|
r274 | ui.status(meu.ui_string( | ||
" %s: password available, source: %s, bound to user %s, url %s\n", | ||||
name, source, user, final_url)) | ||||
Marcin Kasperski
|
r187 | elif user: | ||
Marcin Kasperski
|
r274 | ui.status(meu.ui_string( | ||
" %s: password not available, once entered, will be bound to user %s, url %s\n", | ||||
name, user, final_url)) | ||||
Marcin Kasperski
|
r187 | else: | ||
Marcin Kasperski
|
r274 | ui.status(meu.ui_string( | ||
" %s: password not available, user unknown, url %s\n", | ||||
name, final_url)) | ||||
Marcin Kasperski
|
r184 | |||
Marcin Kasperski
|
r269 | @command(b'keyring_clear', | ||
Romain DEP.
|
r205 | [], | ||
Marcin Kasperski
|
r217 | _('hg keyring_clear PATH-OR-ALIAS'), | ||
optionalrepo=True) | ||||
Marcin Kasperski
|
r191 | def cmd_keyring_clear(ui, repo, path, **opts): # pylint: disable=unused-argument | ||
""" | ||||
Drops password bound to given path (if any is saved). | ||||
Marcin Kasperski
|
r217 | |||
Parameter can be given as full url (``https://John@bitbucket.org``) or as the name | ||||
of path alias (``bitbucket``). | ||||
Marcin Kasperski
|
r189 | """ | ||
Marcin Kasperski
|
r191 | path_url = path | ||
Marcin Kasperski
|
r276 | for name, url in ui.configitems(b'paths'): | ||
Marcin Kasperski
|
r191 | if name == path: | ||
path_url = url | ||||
break | ||||
if not is_http_path(path_url): | ||||
Marcin Kasperski
|
r274 | 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
|
r191 | return | ||
handler = HTTPPasswordHandler() | ||||
user, pwd, source, final_url = handler.get_credentials( | ||||
Marcin Kasperski
|
r240 | make_passwordmgr(ui), path, path_url) | ||
Marcin Kasperski
|
r191 | if not user: | ||
Marcin Kasperski
|
r274 | ui.status(meu.ui_string("Username not configured for url %s\n", | ||
final_url)) | ||||
Marcin Kasperski
|
r191 | return | ||
if not pwd: | ||||
Marcin Kasperski
|
r274 | ui.status(meu.ui_string("No password is saved for user %s, url %s\n", | ||
user, final_url)) | ||||
Marcin Kasperski
|
r191 | return | ||
if source != handler.SRC_KEYRING: | ||||
Marcin Kasperski
|
r274 | 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
|
r191 | |||
password_store.clear_http_password(final_url, user) | ||||
Marcin Kasperski
|
r274 | ui.status(meu.ui_string("Password removed for user %s, url %s\n", | ||
user, final_url)) | ||||
Marcin Kasperski
|
r209 | |||
Marcin Kasperski
|
r283 | buglink = 'https://foss.heptapod.net/mercurial/mercurial_keyring/issues' | ||