annotate easyhg.py @ 459:ee06f9275c99

Use cached password directly, without asking the user, if it's available and the requested realm/uri are not the same as an immediately preceding one
author Chris Cannam
date Thu, 30 Jun 2011 00:04:33 +0100
parents c22cef015d73
children 9f738c3ae9ba
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@453 7 # Copyright (c) 2010-2011 Chris Cannam
Chris@453 8 # Copyright (c) 2010-2011 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@457 47 from PyQt4 import Qt, QtCore, 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@452 65 class EasyHgAuthStore(object):
Chris@431 66
Chris@452 67 def __init__(self, ui, url, user, passwd):
Chris@452 68
Chris@452 69 self.ui = ui
Chris@452 70 self.remote_url = url
Chris@452 71
Chris@452 72 self.user = user
Chris@452 73 self.passwd = passwd
Chris@452 74
Chris@452 75 self.auth_key = self.ui.config('easyhg', 'authkey')
Chris@452 76 self.auth_file = self.ui.config('easyhg', 'authfile')
Chris@452 77
Chris@452 78 self.use_auth_file = (easyhg_authfile_imports_ok and
Chris@452 79 self.auth_key and self.auth_file)
Chris@452 80
Chris@458 81 self.auth_config = None
Chris@458 82 self.auth_cipher = None
Chris@458 83 self.remember = False
Chris@458 84
Chris@452 85 if self.use_auth_file:
Chris@452 86 self.auth_cipher = AES.new(self.auth_key, AES.MODE_CBC)
Chris@452 87 self.auth_file = os.path.expanduser(self.auth_file)
Chris@452 88 self.load_auth_data()
Chris@452 89
Chris@452 90 def save(self):
Chris@452 91 if self.use_auth_file:
Chris@452 92 self.save_auth_data()
Chris@452 93
Chris@452 94 def encrypt(self, text):
Chris@452 95 iv = os.urandom(12)
Chris@452 96 text = '%s.%d.%s.easyhg' % (base64.b64encode(iv), len(text), text)
Chris@452 97 text += (16 - (len(text) % 16)) * ' '
Chris@452 98 ctext = base64.b64encode(self.auth_cipher.encrypt(text))
Chris@452 99 return ctext
Chris@452 100
Chris@452 101 def decrypt(self, ctext):
Chris@448 102 try:
Chris@456 103 text = self.auth_cipher.decrypt(base64.b64decode(ctext))
Chris@456 104 (iv, d, text) = text.partition('.')
Chris@456 105 (tlen, d, text) = text.partition('.')
Chris@452 106 return text[0:int(tlen)]
Chris@448 107 except:
Chris@452 108 self.ui.write("failed to decrypt/convert cached data!")
Chris@452 109 return ''
Chris@452 110
Chris@452 111 def argless_url(self):
Chris@452 112 parsed = urlparse.urlparse(self.remote_url)
Chris@452 113 return "%s://%s%s" % (parsed.scheme, parsed.netloc, parsed.path)
Chris@452 114
Chris@452 115 def pathless_url(self):
Chris@452 116 parsed = urlparse.urlparse(self.remote_url)
Chris@452 117 return "%s://%s" % (parsed.scheme, parsed.netloc)
Chris@452 118
Chris@452 119 def load_config(self):
Chris@453 120 if not self.auth_config:
Chris@453 121 self.auth_config = ConfigParser.RawConfigParser()
Chris@452 122 fp = None
Chris@452 123 try:
Chris@452 124 fp = open(self.auth_file)
Chris@452 125 except:
Chris@452 126 self.ui.write("unable to read authfile %s, ignoring\n" % self.auth_file)
Chris@452 127 return
Chris@452 128 self.auth_config.readfp(fp)
Chris@452 129 fp.close()
Chris@452 130
Chris@452 131 def save_config(self):
Chris@452 132 ofp = None
Chris@452 133 try:
Chris@452 134 ofp = open(self.auth_file, 'w')
Chris@452 135 except:
Chris@452 136 self.ui.write("failed to open authfile %s for writing\n" % self.auth_file)
Chris@448 137 raise
Chris@452 138 if platform.system() != 'Windows':
Chris@452 139 try:
Chris@452 140 os.fchmod(ofp.fileno(), stat.S_IRUSR | stat.S_IWUSR)
Chris@452 141 except:
Chris@452 142 ofp.close()
Chris@452 143 self.ui.write("failed to set permissions on authfile %s\n" % self.auth_file)
Chris@452 144 raise
Chris@452 145 self.auth_config.write(ofp)
Chris@452 146 ofp.close()
Chris@434 147
Chris@452 148 def get_from_config(self, sect, key):
Chris@452 149 data = None
Chris@452 150 try:
Chris@452 151 data = self.auth_config.get(sect, key)
Chris@452 152 except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
Chris@452 153 pass
Chris@452 154 return data
Chris@436 155
Chris@452 156 def get_boolean_from_config(self, sect, key, deflt):
Chris@452 157 data = deflt
Chris@452 158 try:
Chris@452 159 data = self.auth_config.getboolean(sect, key)
Chris@452 160 except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
Chris@452 161 pass
Chris@452 162 return data
Chris@436 163
Chris@452 164 def set_to_config(self, sect, key, data):
Chris@452 165 if not self.auth_config.has_section(sect):
Chris@452 166 self.auth_config.add_section(sect)
Chris@452 167 self.auth_config.set(sect, key, data)
Chris@436 168
Chris@452 169 def remote_key(self, url, user):
Chris@452 170 # generate a "safe-for-config-file" key representing uri+user
Chris@459 171 # self.ui.write('generating remote_key for url %s and user %s\n' % (url, user))
Chris@452 172 s = '%s@@%s' % (url, user)
Chris@452 173 h = hashlib.sha1()
Chris@452 174 h.update(self.auth_key)
Chris@452 175 h.update(s)
Chris@452 176 hx = h.hexdigest()
Chris@452 177 return hx
Chris@452 178
Chris@452 179 def remote_user_key(self):
Chris@453 180 return self.remote_key(self.pathless_url(), '')
Chris@452 181
Chris@452 182 def remote_passwd_key(self):
Chris@452 183 return self.remote_key(self.pathless_url(), self.user)
Chris@452 184
Chris@452 185 def load_auth_data(self):
Chris@452 186
Chris@452 187 self.load_config()
Chris@452 188 if not self.auth_config: return
Chris@452 189
Chris@452 190 self.remember = self.get_boolean_from_config(
Chris@452 191 'preferences', 'remember', False)
Chris@452 192
Chris@452 193 if not self.user:
Chris@452 194 d = self.get_from_config('user', self.remote_user_key())
Chris@452 195 if d:
Chris@452 196 self.user = self.decrypt(d)
Chris@452 197
Chris@452 198 if self.user:
Chris@452 199 d = self.get_from_config('auth', self.remote_passwd_key())
Chris@452 200 if d:
Chris@452 201 self.passwd = self.decrypt(d)
Chris@452 202
Chris@452 203 def save_auth_data(self):
Chris@452 204
Chris@453 205 self.load_config()
Chris@452 206 if not self.auth_config: return
Chris@453 207
Chris@452 208 self.set_to_config('preferences', 'remember', self.remember)
Chris@452 209
Chris@459 210 # self.ui.write('aiming to store details for user %s\n' % self.user)
Chris@452 211
Chris@452 212 if self.remember and self.user:
Chris@452 213 d = self.encrypt(self.user)
Chris@452 214 self.set_to_config('user', self.remote_user_key(), d)
Chris@452 215 else:
Chris@452 216 self.set_to_config('user', self.remote_user_key(), '')
Chris@452 217
Chris@452 218 if self.remember and self.user and self.passwd:
Chris@452 219 d = self.encrypt(self.passwd)
Chris@452 220 self.set_to_config('auth', self.remote_passwd_key(), d)
Chris@452 221 elif self.user:
Chris@452 222 self.set_to_config('auth', self.remote_passwd_key(), '')
Chris@452 223
Chris@452 224 self.save_config()
Chris@452 225
Chris@452 226 class EasyHgAuthDialog(object):
Chris@452 227
Chris@452 228 auth_store = None
Chris@452 229
Chris@452 230 def __init__(self, ui, url, user, passwd):
Chris@452 231 self.auth_store = EasyHgAuthStore(ui, url, user, passwd)
Chris@452 232
Chris@459 233 def ask(self, force_dialog):
Chris@459 234
Chris@459 235 if self.auth_store.user and self.auth_store.passwd:
Chris@459 236 if not force_dialog:
Chris@459 237 return (self.auth_store.user, self.auth_store.passwd)
Chris@459 238
Chris@452 239 dialog = QtGui.QDialog()
Chris@452 240 layout = QtGui.QGridLayout()
Chris@452 241 dialog.setLayout(layout)
Chris@452 242
Chris@452 243 layout.addWidget(QtGui.QLabel(_('<h3>Login required</h3><p>Please provide your login details for the repository at<br><code>%s</code>:') % self.auth_store.argless_url()), 0, 0, 1, 2)
Chris@452 244
Chris@452 245 user_field = QtGui.QLineEdit()
Chris@452 246 if self.auth_store.user: user_field.setText(self.auth_store.user)
Chris@452 247 layout.addWidget(QtGui.QLabel(_('User:')), 1, 0)
Chris@452 248 layout.addWidget(user_field, 1, 1)
Chris@452 249
Chris@452 250 passwd_field = QtGui.QLineEdit()
Chris@452 251 passwd_field.setEchoMode(QtGui.QLineEdit.Password)
Chris@452 252 if self.auth_store.passwd: passwd_field.setText(self.auth_store.passwd)
Chris@452 253 layout.addWidget(QtGui.QLabel(_('Password:')), 2, 0)
Chris@452 254 layout.addWidget(passwd_field, 2, 1)
Chris@452 255
Chris@452 256 user_field.connect(user_field, Qt.SIGNAL("textChanged(QString)"),
Chris@452 257 passwd_field, Qt.SLOT("clear()"))
Chris@452 258
Chris@452 259 remember_field = None
Chris@452 260 if self.auth_store.use_auth_file:
Chris@452 261 remember_field = QtGui.QCheckBox()
Chris@452 262 remember_field.setChecked(self.auth_store.remember)
Chris@452 263 remember_field.setText(_('Remember these details while EasyMercurial is running'))
Chris@452 264 layout.addWidget(remember_field, 3, 1)
Chris@457 265 warning_field = QtGui.QLabel()
Chris@457 266 warning_field.setText(_('<qt><i><small>Note: Do not select this if anyone else has access to this machine!</small></i><br></qt>'))
Chris@457 267 warning_field.hide()
Chris@457 268 remember_field.connect(remember_field, Qt.SIGNAL("clicked()"),
Chris@457 269 warning_field, Qt.SLOT("show()"))
Chris@457 270 layout.addWidget(warning_field, 4, 1, QtCore.Qt.AlignRight)
Chris@452 271
Chris@452 272 bb = QtGui.QDialogButtonBox()
Chris@452 273 ok = bb.addButton(bb.Ok)
Chris@452 274 cancel = bb.addButton(bb.Cancel)
Chris@452 275 cancel.setDefault(False)
Chris@452 276 cancel.setAutoDefault(False)
Chris@452 277 ok.setDefault(True)
Chris@452 278 bb.connect(ok, Qt.SIGNAL("clicked()"), dialog, Qt.SLOT("accept()"))
Chris@452 279 bb.connect(cancel, Qt.SIGNAL("clicked()"), dialog, Qt.SLOT("reject()"))
Chris@457 280 layout.addWidget(bb, 5, 0, 1, 2)
Chris@452 281
Chris@452 282 dialog.setWindowTitle(_('EasyMercurial: Login'))
Chris@452 283 dialog.show()
Chris@452 284
Chris@452 285 if not self.auth_store.user:
Chris@452 286 user_field.setFocus(True)
Chris@452 287 elif not self.auth_store.passwd:
Chris@452 288 passwd_field.setFocus(True)
Chris@452 289 else:
Chris@452 290 ok.setFocus(True)
Chris@452 291
Chris@452 292 dialog.raise_()
Chris@452 293 ok = dialog.exec_()
Chris@452 294 if not ok:
Chris@452 295 raise util.Abort(_('password entry cancelled'))
Chris@452 296
Chris@452 297 self.auth_store.user = user_field.text()
Chris@452 298 self.auth_store.passwd = passwd_field.text()
Chris@452 299
Chris@452 300 if remember_field:
Chris@452 301 self.auth_store.remember = remember_field.isChecked()
Chris@452 302
Chris@452 303 self.auth_store.save()
Chris@452 304
Chris@452 305 return (self.auth_store.user, self.auth_store.passwd)
Chris@438 306
Chris@440 307
Chris@440 308 def uisetup(ui):
Chris@440 309 if not easyhg_pyqt_ok:
Chris@440 310 raise util.Abort(_('Failed to load PyQt4 module required by easyhg.py'))
Chris@440 311 global easyhg_qtapp
Chris@440 312 easyhg_qtapp = QtGui.QApplication([])
Chris@440 313
Chris@440 314 def monkeypatch_method(cls):
Chris@440 315 def decorator(func):
Chris@440 316 setattr(cls, func.__name__, func)
Chris@440 317 return func
Chris@440 318 return decorator
Chris@440 319
Chris@440 320 orig_find = passwordmgr.find_user_password
Chris@440 321
Chris@427 322 @monkeypatch_method(passwordmgr)
Chris@427 323 def find_user_password(self, realm, authuri):
Chris@427 324
Chris@459 325 if not hasattr(self, '__easyhg_last'):
Chris@459 326 self.__easyhg_last = None
Chris@459 327
Chris@427 328 if not self.ui.interactive():
Chris@427 329 return orig_find(self, realm, authuri)
Chris@427 330 if not easyhg_pyqt_ok:
Chris@427 331 return orig_find(self, realm, authuri)
Chris@427 332
Chris@427 333 authinfo = urllib2.HTTPPasswordMgrWithDefaultRealm.find_user_password(
Chris@427 334 self, realm, authuri)
Chris@427 335 user, passwd = authinfo
Chris@427 336
Chris@427 337 if user and passwd:
Chris@459 338 # self.ui.write("note: user and passwd both provided\n")
Chris@427 339 return orig_find(self, realm, authuri)
Chris@427 340
Chris@459 341 # self.ui.write("want username and/or password for %s\n" % authuri)
Chris@459 342
Chris@459 343 force_dialog = False
Chris@459 344
Chris@459 345 # if self.__easyhg_last:
Chris@459 346 # self.ui.write("last = realm '%s' authuri '%s'\n" % self.__easyhg_last)
Chris@459 347 # self.ui.write("me = realm '%s' authuri '%s'\n" % (realm, authuri))
Chris@459 348
Chris@459 349 if (realm, authuri) == self.__easyhg_last:
Chris@459 350 # If we are called again just after identical previous
Chris@459 351 # request, then the previously returned auth must have been
Chris@459 352 # wrong. So we note this to force password prompt (and avoid
Chris@459 353 # reusing bad password indefinitely). Thanks to
Chris@459 354 # mercurial_keyring (Marcin Kasperski) for this logic
Chris@459 355 # self.ui.write("same again!\n")
Chris@459 356 force_dialog = True
Chris@427 357
Chris@452 358 dialog = EasyHgAuthDialog(self.ui, authuri, user, passwd)
Chris@427 359
Chris@459 360 (user, passwd) = dialog.ask(force_dialog)
Chris@433 361
Chris@459 362 # self.add_password(realm, authuri, user, passwd)
Chris@459 363 self.__easyhg_last = (realm, authuri)
Chris@459 364 # self.ui.write("done = realm '%s' authuri '%s'\n" % self.__easyhg_last)
Chris@427 365 return (user, passwd)
Chris@427 366
Chris@427 367