annotate easyhg.py @ 451:8156537329ff

Associate password with scheme+host+user rather than url+user
author Chris Cannam
date Wed, 29 Jun 2011 13:40:57 +0100
parents 568abb678073
children d0b7dbc3ba46
rev   line source
Chris@427 1 # -*- coding: utf-8 -*-
Chris@427 2 #
Chris@427 3 # EasyMercurial
Chris@427 4 #
Chris@427 5 # Based on hgExplorer by Jari Korhonen
Chris@427 6 # Copyright (c) 2010 Jari Korhonen
Chris@427 7 # Copyright (c) 2010 Chris Cannam
Chris@427 8 # Copyright (c) 2010 Queen Mary, University of London
Chris@427 9 #
Chris@427 10 # This program is free software; you can redistribute it and/or
Chris@427 11 # modify it under the terms of the GNU General Public License as
Chris@427 12 # published by the Free Software Foundation; either version 2 of the
Chris@427 13 # License, or (at your option) any later version. See the file
Chris@427 14 # COPYING included with this distribution for more information.
Chris@427 15
Chris@449 16 import sys, os, stat, urllib, urllib2, urlparse, platform, hashlib
Chris@427 17
Chris@438 18 from mercurial.i18n import _
Chris@433 19 from mercurial import ui, util, error
Chris@427 20 try:
Chris@427 21 from mercurial.url import passwordmgr
Chris@427 22 except:
Chris@427 23 from mercurial.httprepo import passwordmgr
Chris@427 24
Chris@427 25 # The value assigned here may be modified during installation, by
Chris@427 26 # replacing its default value with another one. We can't compare
Chris@427 27 # against its default value, because then the comparison text would
Chris@427 28 # get modified as well. So, compare using prefix only.
Chris@427 29 #
Chris@427 30 easyhg_import_path = 'NO_EASYHG_IMPORT_PATH'
Chris@427 31 if not easyhg_import_path.startswith('NO_'):
Chris@427 32 # We have an installation path: append it twice, once with
Chris@427 33 # the Python version suffixed
Chris@440 34 version_suffix = 'Py%d.%d' % (sys.version_info[0], sys.version_info[1])
Chris@427 35 sys.path.append(easyhg_import_path + "/" + version_suffix)
Chris@427 36 sys.path.append(easyhg_import_path)
Chris@427 37
Chris@427 38 # Try to load the PyQt4 module that we need. If this fails, we should
Chris@427 39 # bail out later (in uisetup), because if we bail out now, Mercurial
Chris@427 40 # will just continue without us and report success. The invoking
Chris@427 41 # application needs to be able to discover whether the module load
Chris@427 42 # succeeded or not, so we need to ensure that Mercurial itself returns
Chris@427 43 # failure if it didn't.
Chris@427 44 #
Chris@427 45 easyhg_pyqt_ok = True
Chris@427 46 try:
Chris@427 47 from PyQt4 import Qt, QtGui
Chris@427 48 except ImportError:
Chris@427 49 easyhg_pyqt_ok = False
Chris@427 50 easyhg_qtapp = None
Chris@427 51
Chris@440 52 # These imports are optional, we just can't use the authfile (i.e.
Chris@438 53 # "remember this password") feature without them
Chris@438 54 #
Chris@437 55 easyhg_authfile_imports_ok = True
Chris@437 56 try:
Chris@437 57 from Crypto.Cipher import AES
Chris@437 58 import ConfigParser # Mercurial version won't write files
Chris@437 59 import base64
Chris@437 60 except ImportError:
Chris@445 61 print "EasyHg: Failed to import required modules for authfile support"
Chris@437 62 easyhg_authfile_imports_ok = False
Chris@433 63
Chris@431 64
Chris@439 65 def encrypt_salted(text, key):
Chris@439 66 salt = os.urandom(8)
Chris@439 67 text = '%d.%s.%s' % (len(text), base64.b64encode(salt), text)
Chris@431 68 text += (16 - len(text) % 16) * ' '
Chris@446 69 cipher = AES.new(key, AES.MODE_CBC)
Chris@431 70 return base64.b64encode(cipher.encrypt(text))
Chris@431 71
Chris@439 72 def decrypt_salted(ctext, key):
Chris@446 73 cipher = AES.new(key, AES.MODE_CBC)
Chris@431 74 text = cipher.decrypt(base64.b64decode(ctext))
Chris@431 75 (tlen, d, text) = text.partition('.')
Chris@439 76 (salt, d, text) = text.partition('.')
Chris@431 77 return text[0:int(tlen)]
Chris@431 78
Chris@451 79 def pathless_url(url):
Chris@451 80 parsed_url = urlparse.urlparse(url)
Chris@451 81 return "%s://%s" % (parsed_url.scheme, parsed_url.netloc)
Chris@451 82
Chris@427 83 # from mercurial_keyring by Marcin Kasperski
Chris@451 84 def canonical_url(url):
Chris@451 85 parsed_url = urlparse.urlparse(url)
Chris@427 86 return "%s://%s%s" % (parsed_url.scheme, parsed_url.netloc,
Chris@427 87 parsed_url.path)
Chris@427 88
Chris@436 89 def load_config(pcfg, pfile):
Chris@434 90 fp = None
Chris@434 91 try:
Chris@434 92 fp = open(pfile)
Chris@434 93 except:
Chris@440 94 return
Chris@440 95 pcfg.readfp(fp)
Chris@440 96 fp.close()
Chris@434 97
Chris@444 98 def save_config(ui, pcfg, pfile):
Chris@434 99 ofp = None
Chris@434 100 try:
Chris@434 101 ofp = open(pfile, 'w')
Chris@434 102 except:
Chris@444 103 ui.write("failed to open authfile %s for writing\n" % pfile)
Chris@434 104 raise
Chris@448 105 if platform.system() != 'Windows':
Chris@448 106 try:
Chris@448 107 os.fchmod(ofp.fileno(), stat.S_IRUSR | stat.S_IWUSR)
Chris@448 108 except:
Chris@448 109 ofp.close()
Chris@448 110 ui.write("failed to set permissions on authfile %s\n" % pfile)
Chris@448 111 raise
Chris@434 112 pcfg.write(ofp)
Chris@434 113 ofp.close()
Chris@434 114
Chris@436 115 def get_from_config(pcfg, sect, key):
Chris@436 116 data = None
Chris@436 117 try:
Chris@436 118 data = pcfg.get(sect, key)
Chris@436 119 except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
Chris@436 120 pass
Chris@436 121 return data
Chris@436 122
Chris@436 123 def get_boolean_from_config(pcfg, sect, key, deflt):
Chris@436 124 data = deflt
Chris@436 125 try:
Chris@436 126 data = pcfg.getboolean(sect, key)
Chris@436 127 except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
Chris@436 128 pass
Chris@436 129 return data
Chris@436 130
Chris@436 131 def set_to_config(pcfg, sect, key, data):
Chris@436 132 if not pcfg.has_section(sect):
Chris@436 133 pcfg.add_section(sect)
Chris@436 134 pcfg.set(sect, key, data)
Chris@436 135
Chris@449 136 def remote_key(uri, user, key):
Chris@440 137 # generate a "safe-for-config-file" key representing uri+user
Chris@449 138 s = '%s@@%s' % (uri, user)
Chris@449 139 h = hashlib.sha1()
Chris@449 140 h.update(key)
Chris@449 141 h.update(s)
Chris@449 142 return h.hexdigest()
Chris@438 143
Chris@440 144
Chris@440 145 def uisetup(ui):
Chris@440 146 if not easyhg_pyqt_ok:
Chris@440 147 raise util.Abort(_('Failed to load PyQt4 module required by easyhg.py'))
Chris@440 148 global easyhg_qtapp
Chris@440 149 easyhg_qtapp = QtGui.QApplication([])
Chris@440 150
Chris@440 151 def monkeypatch_method(cls):
Chris@440 152 def decorator(func):
Chris@440 153 setattr(cls, func.__name__, func)
Chris@440 154 return func
Chris@440 155 return decorator
Chris@440 156
Chris@440 157 orig_find = passwordmgr.find_user_password
Chris@440 158
Chris@427 159 @monkeypatch_method(passwordmgr)
Chris@427 160 def find_user_password(self, realm, authuri):
Chris@427 161
Chris@427 162 if not self.ui.interactive():
Chris@427 163 return orig_find(self, realm, authuri)
Chris@427 164 if not easyhg_pyqt_ok:
Chris@427 165 return orig_find(self, realm, authuri)
Chris@427 166
Chris@427 167 authinfo = urllib2.HTTPPasswordMgrWithDefaultRealm.find_user_password(
Chris@427 168 self, realm, authuri)
Chris@427 169 user, passwd = authinfo
Chris@427 170
Chris@427 171 if user and passwd:
Chris@427 172 return orig_find(self, realm, authuri)
Chris@427 173
Chris@440 174 # self.ui.write("want username and/or password for %s\n" % authuri)
Chris@427 175
Chris@440 176 short_uri = canonical_url(authuri)
Chris@451 177 pathless_uri = pathless_url(authuri)
Chris@427 178
Chris@440 179 authkey = self.ui.config('easyhg', 'authkey')
Chris@440 180 authfile = self.ui.config('easyhg', 'authfile')
Chris@440 181 use_authfile = (easyhg_authfile_imports_ok and authkey and authfile)
Chris@450 182 if authfile: authfile = os.path.expanduser(authfile)
Chris@427 183
Chris@427 184 dialog = QtGui.QDialog()
Chris@427 185 layout = QtGui.QGridLayout()
Chris@427 186 dialog.setLayout(layout)
Chris@427 187
Chris@440 188 layout.addWidget(QtGui.QLabel(_('<h3>Login required</h3><p>Please provide your login details for the repository at<br><code>%s</code>:') % short_uri), 0, 0, 1, 2)
Chris@427 189
Chris@440 190 user_field = QtGui.QLineEdit()
Chris@427 191 if user:
Chris@440 192 user_field.setText(user)
Chris@427 193 layout.addWidget(QtGui.QLabel(_('User:')), 1, 0)
Chris@440 194 layout.addWidget(user_field, 1, 1)
Chris@427 195
Chris@440 196 passwd_field = QtGui.QLineEdit()
Chris@440 197 passwd_field.setEchoMode(QtGui.QLineEdit.Password)
Chris@427 198 if passwd:
Chris@440 199 passwd_field.setText(passwd)
Chris@427 200 layout.addWidget(QtGui.QLabel(_('Password:')), 2, 0)
Chris@440 201 layout.addWidget(passwd_field, 2, 1)
Chris@427 202
Chris@440 203 user_field.connect(user_field, Qt.SIGNAL("textChanged(QString)"),
Chris@440 204 passwd_field, Qt.SLOT("clear()"))
Chris@435 205
Chris@440 206 remember_field = None
Chris@440 207 remember = False
Chris@450 208
Chris@440 209 authconfig = None
Chris@450 210 authdata = None
Chris@433 211
Chris@436 212 if use_authfile:
Chris@450 213
Chris@440 214 authconfig = ConfigParser.RawConfigParser()
Chris@440 215 load_config(authconfig, authfile)
Chris@440 216 remember = get_boolean_from_config(authconfig, 'preferences',
Chris@440 217 'remember', False)
Chris@450 218
Chris@450 219 if not user:
Chris@450 220 authdata = get_from_config(authconfig, 'user',
Chris@450 221 remote_key(short_uri, '', authkey))
Chris@450 222 if authdata:
Chris@450 223 user = decrypt_salted(authdata, authkey)
Chris@450 224 user_field.setText(user)
Chris@450 225
Chris@450 226 if not passwd:
Chris@450 227 authdata = get_from_config(authconfig, 'auth',
Chris@451 228 remote_key(pathless_uri, user, authkey))
Chris@450 229 if authdata:
Chris@450 230 passwd = decrypt_salted(authdata, authkey)
Chris@450 231 passwd_field.setText(passwd)
Chris@450 232
Chris@440 233 remember_field = QtGui.QCheckBox()
Chris@440 234 remember_field.setChecked(remember)
Chris@450 235 remember_field.setText(_('Remember these details while EasyMercurial is running'))
Chris@440 236 layout.addWidget(remember_field, 3, 1)
Chris@431 237
Chris@427 238 bb = QtGui.QDialogButtonBox()
Chris@427 239 ok = bb.addButton(bb.Ok)
Chris@427 240 cancel = bb.addButton(bb.Cancel)
Chris@427 241 cancel.setDefault(False)
Chris@427 242 cancel.setAutoDefault(False)
Chris@427 243 ok.setDefault(True)
Chris@427 244 bb.connect(ok, Qt.SIGNAL("clicked()"), dialog, Qt.SLOT("accept()"))
Chris@427 245 bb.connect(cancel, Qt.SIGNAL("clicked()"), dialog, Qt.SLOT("reject()"))
Chris@431 246 layout.addWidget(bb, 4, 0, 1, 2)
Chris@427 247
Chris@428 248 dialog.setWindowTitle(_('EasyMercurial: Login'))
Chris@427 249 dialog.show()
Chris@428 250
Chris@428 251 if not user:
Chris@440 252 user_field.setFocus(True)
Chris@428 253 elif not passwd:
Chris@440 254 passwd_field.setFocus(True)
Chris@450 255 else:
Chris@450 256 ok.setFocus(True)
Chris@428 257
Chris@427 258 dialog.raise_()
Chris@427 259 ok = dialog.exec_()
Chris@436 260 if not ok:
Chris@436 261 raise util.Abort(_('password entry cancelled'))
Chris@431 262
Chris@440 263 user = user_field.text()
Chris@440 264 passwd = passwd_field.text()
Chris@432 265
Chris@436 266 if use_authfile:
Chris@440 267 remember = remember_field.isChecked()
Chris@440 268 set_to_config(authconfig, 'preferences', 'remember', remember)
Chris@438 269 if user:
Chris@450 270 if remember:
Chris@450 271 authdata = encrypt_salted(user, authkey)
Chris@450 272 set_to_config(authconfig, 'user', remote_key(short_uri, '', authkey), authdata)
Chris@450 273 else:
Chris@450 274 set_to_config(authconfig, 'user', remote_key(short_uri, '', authkey), '')
Chris@450 275 if passwd:
Chris@450 276 if remember:
Chris@440 277 authdata = encrypt_salted(passwd, authkey)
Chris@451 278 set_to_config(authconfig, 'auth', remote_key(pathless_uri, user, authkey), authdata)
Chris@438 279 else:
Chris@451 280 set_to_config(authconfig, 'auth', remote_key(pathless_uri, user, authkey), '')
Chris@444 281 save_config(self.ui, authconfig, authfile)
Chris@433 282
Chris@436 283 self.add_password(realm, authuri, user, passwd)
Chris@427 284 return (user, passwd)
Chris@427 285
Chris@427 286