##// END OF EJS Templates
merge
Marcin Kasperski -
r173:817841ec merge default
parent child Browse files
Show More
This diff has been collapsed as it changes many lines, (1256 lines changed) Show them Hide them
@@ -1,611 +1,645 b''
1 # -*- coding: utf-8 -*-
2 #
3 # mercurial_keyring: save passwords in password database
4 #
5 # Copyright (c) 2009 Marcin Kasperski <Marcin.Kasperski@mekk.waw.pl>
6 # All rights reserved.
7 #
8 # Redistribution and use in source and binary forms, with or without
9 # modification, are permitted provided that the following conditions
10 # are met:
11 # 1. Redistributions of source code must retain the above copyright
12 # notice, this list of conditions and the following disclaimer.
13 # 2. Redistributions in binary form must reproduce the above copyright
14 # notice, this list of conditions and the following disclaimer in the
15 # documentation and/or other materials provided with the distribution.
16 # 3. The name of the author may not be used to endorse or promote products
17 # derived from this software without specific prior written permission.
18 #
19 # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
20 # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
21 # OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
22 # IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
23 # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
24 # NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
28 # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 #
30 # See README.txt for more details.
31
32 ''' securely save HTTP and SMTP authentication details
33 mercurial_keyring is a Mercurial extension used to securely save
34 HTTP and SMTP authentication passwords in password databases (Gnome
35 Keyring, KDE KWallet, OSXKeyChain, specific solutions for Win32 and
36 command line). This extension uses and wraps services of the keyring
37 library.
38 '''
39
40 from mercurial import util, sslutil
41 from mercurial.i18n import _
42 try:
43 from mercurial.url import passwordmgr
44 except:
45 from mercurial.httprepo import passwordmgr
46 from mercurial import mail
47 from mercurial.mail import SMTPS, STARTTLS
48 from mercurial import encoding
49
50 from urlparse import urlparse
51 import urllib2
52 import smtplib
53 import socket
54 import os
55 import sys
56
57 def import_meu():
58 """Imports and returns mercurial_extension_utils module.
59 Workaround for TortoiseHg/Win environment, where this module
60 can'b be easily put on PYTHONPATH"""
61 try:
62 import mercurial_extension_utils
63 except ImportError:
64 my_dir = os.path.dirname(__file__)
65 sys.path.extend([
66 # In the same dir (manual or site-packages after pip)
67 my_dir,
68 # Developer clone
69 os.path.join(os.path.dirname(my_dir), "extension_utils"),
70 # Side clone
71 os.path.join(os.path.dirname(my_dir), "mercurial-extension_utils"),
72 ])
73 try:
74 import mercurial_extension_utils
75 except ImportError:
76 raise util.Abort(_("""Can not import mercurial_extension_utils.
77 Please install this module in Python path.
78 See Installation chapter in https://bitbucket.org/Mekk/mercurial-dynamic_username/ for details
79 (and for info about TortoiseHG on Windows, or other bundled Python)."""))
80 return mercurial_extension_utils
81
82 meu = import_meu()
83 monkeypatch_method = meu.monkeypatch_method
84
85
86 def import_keyring():
87 """
88 Importing keyring happens to be costly if wallet is slow, so we delay it
89 until really needed.
90 """
91 if 'keyring' in sys.modules:
92 return sys.modules['keyring']
93 # mercurial.demandimport incompatibility workaround.
94 # various keyring backends fail as they can't properly import helper
95 # modules (as demandimport modifies python import behaviour).
96 # If you get import errors with demandimport in backtrace, try
97 # guessing what to block and extending the list below.
98 from mercurial import demandimport
99 for blocked_module in [
100 "gobject._gobject",
101 "configparser",
102 "json",
103 "abc",
104 "io",
105 "keyring",
106 "gdata.docs.service",
107 "gdata.service",
108 "types",
109 "atom.http",
110 "atom.http_interface",
111 "atom.service",
112 "atom.token_store",
113 "ctypes",
114 "secretstorage.exceptions",
115 "fs.opener",
116 ]:
117 if blocked_module not in demandimport.ignore:
118 demandimport.ignore.append(blocked_module)
119 try:
120 is_demandimport_enabled = demandimport.isenabled
121 except AttributeError:
122 # Mercurial < 2.9.1
123 try:
124 orig_demandimport = demandimport.demandimport
125 except AttributeError:
126 orig_demandimport = demandimport._demandimport
127 def is_demandimport_enabled():
128 """Checks whether demandimport is enabled at the moment"""
129 return __import__ == orig_demandimport
130
131 # Shut up warning about uninitialized logging for new keyring versions.
132 # But beware 2.6…
133 try:
134 import logging
135 logging.getLogger("keyring").addHandler(logging.NullHandler())
136 except:
137 pass
138
139 # Temporarily disable demandimport to make the need of extending
140 # the list above less likely.
141 if is_demandimport_enabled():
142 demandimport.disable()
143 try:
144 import keyring
145 finally:
146 demandimport.enable()
147 else:
148 import keyring
149 return keyring
150
151 KEYRING_SERVICE = "Mercurial"
152
153 ############################################################
154
155 class PasswordStore(object):
156 """
157 Helper object handling keyring usage (password save&restore,
158 the way passwords are keyed in the keyring).
159 """
160 def __init__(self):
161 self.cache = dict()
162
163 def get_http_password(self, url, username):
164 """
165 Checks whether password of username for url is available,
166 returns it or None
167 """
168 return self._read_password_from_keyring(
169 self._format_http_key(url, username))
170
171 def set_http_password(self, url, username, password):
172 """Saves password to keyring"""
173 self._save_password_to_keyring(
174 self._format_http_key(url, username),
175 password)
176
177 def clear_http_password(self, url, username):
178 """Drops saved password"""
179 self.set_http_password(url, username, "")
180
181 def _format_http_key(self, url, username):
182 """Construct actual key for password identification"""
183 return "%s@@%s" % (username, url)
184
185 def get_smtp_password(self, machine, port, username):
186 """Checks for SMTP password in keyring, returns
187 password or None"""
188 return self._read_password_from_keyring(
189 self._format_smtp_key(machine, port, username))
190
191 def set_smtp_password(self, machine, port, username, password):
192 """Saves SMTP password to keyring"""
193 self._save_password_to_keyring(
194 self._format_smtp_key(machine, port, username),
195 password)
196
197 def clear_smtp_password(self, machine, port, username):
198 """Drops saved SMTP password"""
199 self.set_smtp_password(machine, port, username, "")
200
201 def _format_smtp_key(self, machine, port, username):
202 """Construct key for SMTP password identification"""
203 return "%s@@%s:%s" % (username, machine, str(port))
204
205 def _read_password_from_keyring(self, pwdkey):
206 """Physically read from keyring"""
207 keyring = import_keyring()
208 password = keyring.get_password(KEYRING_SERVICE, pwdkey)
209 # Reverse recoding from next routine
210 if isinstance(password, unicode):
211 return encoding.tolocal(password.encode('utf-8'))
212 return password
213
214 def _save_password_to_keyring(self, pwdkey, password):
215 """Physically write to keyring"""
216 keyring = import_keyring()
217 # keyring in general expects unicode.
218 # Mercurial provides "local" encoding. See #33
219 password = encoding.fromlocal(password).decode('utf-8')
220 keyring.set_password(
221 KEYRING_SERVICE, pwdkey, password)
222
223 password_store = PasswordStore()
224
225 ############################################################
226
227 def _debug(ui, msg):
228 ui.debug("keyring: " + msg + "\n")
229
230 def _debug_reply(ui, msg, url, user, pwd):
231 _debug(ui, "%s. Url: %s, user: %s, passwd: %s" % (
232 msg, url, user, pwd and '*' * len(pwd) or 'not set'))
233
234
235 ############################################################
236
237 class HTTPPasswordHandler(object):
238 """
239 Actual implementation of password handling (user prompting,
240 configuration file searching, keyring save&restore).
241
242 Object of this class is bound as passwordmgr attribute.
243 """
244 def __init__(self):
245 self.pwd_cache = {}
246 self.last_reply = None
247
248 def find_auth(self, pwmgr, realm, authuri, req):
249 """
250 Actual implementation of find_user_password - different
251 ways of obtaining the username and password.
252 """
253 ui = pwmgr.ui
254
255 # If we are called again just after identical previous
256 # request, then the previously returned auth must have been
257 # wrong. So we note this to force password prompt (and avoid
258 # reusing bad password indifinitely).
259 after_bad_auth = (self.last_reply \
260 and (self.last_reply['realm'] == realm) \
261 and (self.last_reply['authuri'] == authuri) \
262 and (self.last_reply['req'] == req))
263 if after_bad_auth:
264 _debug(ui, _("Working after bad authentication, cached passwords not used %s") % str(self.last_reply))
265
266 # Strip arguments to get actual remote repository url.
267 base_url = self.canonical_url(authuri)
268
269 # Extracting possible username (or password)
270 # stored directly in repository url
271 user, pwd = urllib2.HTTPPasswordMgrWithDefaultRealm.find_user_password(
272 pwmgr, realm, authuri)
273 if user and pwd:
274 _debug_reply(ui, _("Auth data found in repository URL"),
275 base_url, user, pwd)
276 self.last_reply = dict(
277 realm=realm, authuri=authuri, user=user,req=req)
278 return user, pwd
279
280 # Loading .hg/hgrc [auth] section contents. If prefix is given,
281 # it will be used as a key to lookup password in the keyring.
282 auth_user, pwd, prefix_url = self.load_hgrc_auth(ui, base_url, user)
283 if prefix_url:
284 keyring_url = prefix_url
285 else:
286 keyring_url = base_url
287 _debug(ui, _("Keyring URL: %s") % keyring_url)
288
289 # Checking the memory cache (there may be many http calls per command)
290 cache_key = (realm, keyring_url)
291 if not after_bad_auth:
292 cached_auth = self.pwd_cache.get(cache_key)
293 if cached_auth:
294 user, pwd = cached_auth
295 _debug_reply(ui, _("Cached auth data found"),
296 base_url, user, pwd)
297 self.last_reply = dict(realm=realm,authuri=authuri,user=user,req=req)
298 return user, pwd
299
300 if auth_user:
301 if user and (user != auth_user):
302 raise util.Abort(_('mercurial_keyring: username for %s specified both in repository path (%s) and in .hg/hgrc/[auth] (%s). Please, leave only one of those' % (base_url, user, auth_user)))
303 user = auth_user
304 if pwd:
305 self.pwd_cache[cache_key] = user, pwd
306 _debug_reply(ui, _("Auth data set in .hg/hgrc"),
307 base_url, user, pwd)
308 self.last_reply = dict(realm=realm,authuri=authuri,user=user,req=req)
309 return user, pwd
310 else:
311 _debug(ui, _("Username found in .hg/hgrc: %s") % user)
312
313 # Loading password from keyring.
314 # Only if username is known (so we know the key) and we are
315 # not after failure (so we don't reuse the bad password).
316 if user and not after_bad_auth:
317 _debug(ui, _("Looking for password for user %s and url %s") % (user, keyring_url))
318 pwd = password_store.get_http_password(keyring_url, user)
319 if pwd:
320 self.pwd_cache[cache_key] = user, pwd
321 _debug_reply(ui, _("Keyring password found"),
322 base_url, user, pwd)
323 self.last_reply = dict(realm=realm,authuri=authuri,user=user,req=req)
324 return user, pwd
325 else:
326 _debug(ui, _("Password not present in the keyring"))
327
328 # Is the username permanently set?
329 fixed_user = (user and True or False)
330
331 # Last resort: interactive prompt
332 if not ui.interactive():
333 raise util.Abort(_('mercurial_keyring: http authorization required but program used in non-interactive mode'))
334
335 if not fixed_user:
336 ui.status(_("Username not specified in .hg/hgrc. Keyring will not be used.\n"))
337
338 ui.write(_("http authorization required\n"))
339 ui.status(_("realm: %s\n") % realm)
340 if fixed_user:
341 ui.write(_("user: %s (fixed in .hg/hgrc)\n" % user))
342 else:
343 user = ui.prompt(_("user:"), default=None)
344 pwd = ui.getpass(_("password: "))
345
346 if fixed_user:
347 # Saving password to the keyring.
348 # It is done only if username is permanently set.
349 # Otherwise we won't be able to find the password so it
350 # does not make much sense to preserve it
351 _debug(ui, _("Saving password for %s to keyring") % user)
352 password_store.set_http_password(keyring_url, user, pwd)
353
354 # Saving password to the memory cache
355 self.pwd_cache[cache_key] = user, pwd
356
357 _debug_reply(ui, _("Manually entered password"),
358 base_url, user, pwd)
359 self.last_reply = dict(realm=realm,authuri=authuri,user=user,req=req)
360 return user, pwd
361
362 def load_hgrc_auth(self, ui, base_url, user):
363 """
364 Loading [auth] section contents from local .hgrc
365
366 Returns (username, password, prefix) tuple (every
367 element can be None)
368 """
369 # Theoretically 3 lines below should do:
370
371 #auth_token = self.readauthtoken(base_url)
372 #if auth_token:
373 # user, pwd = auth.get('username'), auth.get('password')
374
375 # Unfortunately they do not work, readauthtoken always return
376 # None. Why? Because ui (self.ui of passwordmgr) describes the
377 # *remote* repository, so does *not* contain any option from
378 # local .hg/hgrc.
379
380 # TODO: mercurial 1.4.2 is claimed to resolve this problem
381 # (thanks to: http://hg.xavamedia.nl/mercurial/crew/rev/fb45c1e4396f)
382 # so since this version workaround implemented below should
383 # not be necessary. As it will take some time until people
384 # migrate to >= 1.4.2, it would be best to implement
385 # workaround conditionally.
386
387 # Workaround: we recreate the repository object
388 repo_root = ui.config("bundle", "mainreporoot")
389
390 from mercurial.ui import ui as _ui
391 local_ui = _ui(ui)
392 if repo_root:
393 local_ui.readconfig(os.path.join(repo_root, ".hg", "hgrc"))
394 try:
395 local_passwordmgr = passwordmgr(local_ui)
396 auth_token = local_passwordmgr.readauthtoken(base_url)
397 except AttributeError:
398 try:
399 # hg 1.8
400 import mercurial.url
401 readauthforuri = mercurial.url.readauthforuri
402 except (ImportError, AttributeError):
403 # hg 1.9
404 import mercurial.httpconnection
405 readauthforuri = mercurial.httpconnection.readauthforuri
406 if readauthforuri.func_code.co_argcount == 3:
407 # Since hg.0593e8f81c71
408 res = readauthforuri(local_ui, base_url, user)
409 else:
410 res = readauthforuri(local_ui, base_url)
411 if res:
412 group, auth_token = res
413 else:
414 auth_token = None
415 if auth_token:
416 username = auth_token.get('username')
417 password = auth_token.get('password')
418 prefix = auth_token.get('prefix')
419 shortest_url = self.shortest_url(base_url, prefix)
420 return username, password, shortest_url
421
422 return None, None, None
423
424 def shortest_url(self, base_url, prefix):
425 if not prefix or prefix == '*':
426 return base_url
427 scheme, hostpath = base_url.split('://', 1)
428 p = prefix.split('://', 1)
429 if len(p) > 1:
430 prefix_host_path = p[1]
431 else:
432 prefix_host_path = prefix
433 shortest_url = scheme + '://' + prefix_host_path
434 return shortest_url
435
436 def canonical_url(self, authuri):
437 """
438 Strips query params from url. Used to convert urls like
439 https://repo.machine.com/repos/apps/module?pairs=0000000000000000000000000000000000000000-0000000000000000000000000000000000000000&cmd=between
440 to
441 https://repo.machine.com/repos/apps/module
442 """
443 parsed_url = urlparse(authuri)
444 return "%s://%s%s" % (parsed_url.scheme, parsed_url.netloc,
445 parsed_url.path)
446
447 ############################################################
448
449 @monkeypatch_method(passwordmgr)
450 def find_user_password(self, realm, authuri):
451 """
452 keyring-based implementation of username/password query
453 for HTTP(S) connections
454
455 Passwords are saved in gnome keyring, OSX/Chain or other platform
456 specific storage and keyed by the repository url
457 """
458 # Extend object attributes
459 if not hasattr(self, '_pwd_handler'):
460 self._pwd_handler = HTTPPasswordHandler()
461
462 if hasattr(self, '_http_req'):
463 req = self._http_req
464 else:
465 req = None
466
467 return self._pwd_handler.find_auth(self, realm, authuri, req)
468
469 @monkeypatch_method(urllib2.AbstractBasicAuthHandler, "http_error_auth_reqed")
470 def basic_http_error_auth_reqed(self, authreq, host, req, headers):
471 self.passwd._http_req = req
472 try:
473 return basic_http_error_auth_reqed.orig(self, authreq, host, req, headers)
474 finally:
475 self.passwd._http_req = None
476
477 @monkeypatch_method(urllib2.AbstractDigestAuthHandler, "http_error_auth_reqed")
478 def digest_http_error_auth_reqed(self, authreq, host, req, headers):
479 self.passwd._http_req = req
480 try:
481 return digest_http_error_auth_reqed.orig(self, authreq, host, req, headers)
482 finally:
483 self.passwd._http_req = None
484
485 ############################################################
486
487 def try_smtp_login(ui, smtp_obj, username, password):
488 """
489 Attempts smtp login on smtp_obj (smtplib.SMTP) using username and
490 password.
491
492 Returns:
493 - True if login succeeded
494 - False if login failed due to the wrong credentials
495
496 Throws Abort exception if login failed for any other reason.
497
498 Immediately returns False if password is empty
499 """
500 if not password:
501 return False
502 try:
503 ui.note(_('(authenticating to mail server as %s)\n') %
504 (username))
505 smtp_obj.login(username, password)
506 return True
507 except smtplib.SMTPException, inst:
508 if inst.smtp_code == 535:
509 ui.status(_("SMTP login failed: %s\n\n") % inst.smtp_error)
510 return False
511 else:
512 raise util.Abort(inst)
513
514 def keyring_supported_smtp(ui, username):
515 """
516 keyring-integrated replacement for mercurial.mail._smtp
517 Used only when configuration file contains username, but
518 does not contain the password.
519
520 Most of the routine below is copied as-is from
521 mercurial.mail._smtp. The only changed part is
522 marked with #>>>>> and #<<<<< markers
523 """
524 local_hostname = ui.config('smtp', 'local_hostname')
525 tls = ui.config('smtp', 'tls', 'none')
526 # backward compatible: when tls = true, we use starttls.
527 starttls = tls == 'starttls' or util.parsebool(tls)
528 smtps = tls == 'smtps'
529 if (starttls or smtps) and not util.safehasattr(socket, 'ssl'):
530 raise util.Abort(_("can't use TLS: Python SSL support not installed"))
531 mailhost = ui.config('smtp', 'host')
532 if not mailhost:
533 raise util.Abort(_('smtp.host not configured - cannot send mail'))
534 verifycert = ui.config('smtp', 'verifycert', 'strict')
535 if verifycert not in ['strict', 'loose']:
536 if util.parsebool(verifycert) is not False:
537 raise util.Abort(_('invalid smtp.verifycert configuration: %s')
538 % (verifycert))
539 verifycert = False
540 if (starttls or smtps) and verifycert:
541 sslkwargs = sslutil.sslkwargs(ui, mailhost)
542 else:
543 sslkwargs = {}
544 if smtps:
545 ui.note(_('(using smtps)\n'))
546 s = SMTPS(sslkwargs, local_hostname=local_hostname)
547 elif starttls:
548 s = STARTTLS(sslkwargs, local_hostname=local_hostname)
549 else:
550 s = smtplib.SMTP(local_hostname=local_hostname)
551 if smtps:
552 defaultport = 465
553 else:
554 defaultport = 25
555 mailport = util.getport(ui.config('smtp', 'port', defaultport))
556 ui.note(_('sending mail: smtp host %s, port %s\n') %
557 (mailhost, mailport))
558 s.connect(host=mailhost, port=mailport)
559 if starttls:
560 ui.note(_('(using starttls)\n'))
561 s.ehlo()
562 s.starttls()
563 s.ehlo()
564 if (starttls or smtps) and verifycert:
565 ui.note(_('(verifying remote certificate)\n'))
566 sslutil.validator(ui, mailhost)(s.sock, verifycert == 'strict')
567
568 #>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
569 stored = password = password_store.get_smtp_password(
570 mailhost, mailport, username)
571 # No need to check whether password was found as try_smtp_login
572 # just returns False if it is absent.
573 while not try_smtp_login(ui, s, username, password):
574 password = ui.getpass(_("Password for %s on %s:%d: ") % (username, mailhost, mailport))
575
576 if stored != password:
577 password_store.set_smtp_password(
578 mailhost, mailport, username, password)
579 #<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
580
581 def send(sender, recipients, msg):
582 try:
583 return s.sendmail(sender, recipients, msg)
584 except smtplib.SMTPRecipientsRefused, inst:
585 recipients = [r[1] for r in inst.recipients.values()]
586 raise util.Abort('\n' + '\n'.join(recipients))
587 except smtplib.SMTPException, inst:
588 raise util.Abort(inst)
589
590 return send
591
592 ############################################################
593
594 orig_smtp = mail._smtp
595
596 @monkeypatch_method(mail)
597 def _smtp(ui):
598 """
599 build an smtp connection and return a function to send email
600
601 This is the monkeypatched version of _smtp(ui) function from
602 mercurial/mail.py. It calls the original unless username
603 without password is given in the configuration.
604 """
605 username = ui.config('smtp', 'username')
606 password = ui.config('smtp', 'password')
607
608 if username and not password:
609 return keyring_supported_smtp(ui, username)
610 else:
611 return orig_smtp(ui)
1 # -*- coding: utf-8 -*-
2 #
3 # mercurial_keyring: save passwords in password database
4 #
5 # Copyright (c) 2009 Marcin Kasperski <Marcin.Kasperski@mekk.waw.pl>
6 # All rights reserved.
7 #
8 # Redistribution and use in source and binary forms, with or without
9 # modification, are permitted provided that the following conditions
10 # are met:
11 # 1. Redistributions of source code must retain the above copyright
12 # notice, this list of conditions and the following disclaimer.
13 # 2. Redistributions in binary form must reproduce the above copyright
14 # notice, this list of conditions and the following disclaimer in the
15 # documentation and/or other materials provided with the distribution.
16 # 3. The name of the author may not be used to endorse or promote products
17 # derived from this software without specific prior written permission.
18 #
19 # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
20 # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
21 # OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
22 # IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
23 # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
24 # NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
28 # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 #
30 # See README.txt for more details.
31
32 ''' securely save HTTP and SMTP authentication details
33 mercurial_keyring is a Mercurial extension used to securely save
34 HTTP and SMTP authentication passwords in password databases (Gnome
35 Keyring, KDE KWallet, OSXKeyChain, specific solutions for Win32 and
36 command line). This extension uses and wraps services of the keyring
37 library.
38 '''
39
40 from mercurial import util, sslutil
41 from mercurial.i18n import _
42 try:
43 from mercurial.url import passwordmgr
44 except:
45 from mercurial.httprepo import passwordmgr
46 from mercurial import mail
47 from mercurial.mail import SMTPS, STARTTLS
48 from mercurial import encoding
49
50 from urlparse import urlparse
51 import urllib2
52 import smtplib
53 import socket
54 import os
55 import sys
56
57 # pylint:disable=invalid-name
58
59 ###########################################################################
60 # Specific import trickery
61 ###########################################################################
62
63
64 def import_meu():
65 """
66 Convoluted import of mercurial_extension_utils, which helps
67 TortoiseHg/Win setups. This routine and it's use below
68 performs equivalent of
69 from mercurial_extension_utils import monkeypatch_method
70 but looks for some non-path directories.
71 """
72 try:
73 import mercurial_extension_utils
74 except ImportError:
75 my_dir = os.path.dirname(__file__)
76 sys.path.extend([
77 # In the same dir (manual or site-packages after pip)
78 my_dir,
79 # Developer clone
80 os.path.join(os.path.dirname(my_dir), "extension_utils"),
81 # Side clone
82 os.path.join(os.path.dirname(my_dir), "mercurial-extension_utils"),
83 ])
84 try:
85 import mercurial_extension_utils
86 except ImportError:
87 raise util.Abort(_("""Can not import mercurial_extension_utils.
88 Please install this module in Python path.
89 See Installation chapter in https://bitbucket.org/Mekk/mercurial-dynamic_username/ for details
90 (and for info about TortoiseHG on Windows, or other bundled Python)."""))
91 return mercurial_extension_utils
92
93 meu = import_meu()
94 monkeypatch_method = meu.monkeypatch_method
95
96
97 def import_keyring():
98 """
99 Importing keyring happens to be costly if wallet is slow, so we delay it
100 until it is really needed. The routine below also works around various
101 demandimport-related problems.
102 """
103 if 'keyring' in sys.modules:
104 return sys.modules['keyring']
105 # mercurial.demandimport incompatibility workaround.
106 # various keyring backends fail as they can't properly import helper
107 # modules (as demandimport modifies python import behaviour).
108 # If you get import errors with demandimport in backtrace, try
109 # guessing what to block and extending the list below.
110 from mercurial import demandimport
111 for blocked_module in [
112 "gobject._gobject",
113 "configparser",
114 "json",
115 "abc",
116 "io",
117 "keyring",
118 "gdata.docs.service",
119 "gdata.service",
120 "types",
121 "atom.http",
122 "atom.http_interface",
123 "atom.service",
124 "atom.token_store",
125 "ctypes",
126 "secretstorage.exceptions",
127 "fs.opener",
128 ]:
129 if blocked_module not in demandimport.ignore:
130 demandimport.ignore.append(blocked_module)
131
132 # Various attempts to define is_demandimport_enabled
133 try:
134 is_demandimport_enabled = demandimport.isenabled
135 except AttributeError:
136 # Mercurial < 2.9.1
137 try:
138 orig_demandimport = demandimport.demandimport
139 except AttributeError:
140 orig_demandimport = demandimport._demandimport
141
142 def is_demandimport_enabled():
143 """Checks whether demandimport is enabled at the moment"""
144 return __import__ == orig_demandimport
145
146 # Shut up warning about uninitialized logging for new keyring versions.
147 # But beware 2.6…
148 try:
149 import logging
150 logging.getLogger("keyring").addHandler(logging.NullHandler())
151 except:
152 pass
153
154 # Temporarily disable demandimport to make the need of extending
155 # the list above less likely.
156 if is_demandimport_enabled():
157 demandimport.disable()
158 try:
159 import keyring
160 finally:
161 demandimport.enable()
162 else:
163 import keyring
164 return keyring
165
166 #################################################################
167 # Actual implementation
168 #################################################################
169
170 KEYRING_SERVICE = "Mercurial"
171
172
173 class PasswordStore(object):
174 """
175 Helper object handling keyring usage (password save&restore,
176 the way passwords are keyed in the keyring).
177 """
178 def __init__(self):
179 self.cache = dict()
180
181 def get_http_password(self, url, username):
182 """
183 Checks whether password of username for url is available,
184 returns it or None
185 """
186 return self._read_password_from_keyring(
187 self._format_http_key(url, username))
188
189 def set_http_password(self, url, username, password):
190 """Saves password to keyring"""
191 self._save_password_to_keyring(
192 self._format_http_key(url, username),
193 password)
194
195 def clear_http_password(self, url, username):
196 """Drops saved password"""
197 self.set_http_password(url, username, "")
198
199 def _format_http_key(self, url, username):
200 """Construct actual key for password identification"""
201 return "%s@@%s" % (username, url)
202
203 def get_smtp_password(self, machine, port, username):
204 """Checks for SMTP password in keyring, returns
205 password or None"""
206 return self._read_password_from_keyring(
207 self._format_smtp_key(machine, port, username))
208
209 def set_smtp_password(self, machine, port, username, password):
210 """Saves SMTP password to keyring"""
211 self._save_password_to_keyring(
212 self._format_smtp_key(machine, port, username),
213 password)
214
215 def clear_smtp_password(self, machine, port, username):
216 """Drops saved SMTP password"""
217 self.set_smtp_password(machine, port, username, "")
218
219 def _format_smtp_key(self, machine, port, username):
220 """Construct key for SMTP password identification"""
221 return "%s@@%s:%s" % (username, machine, str(port))
222
223 def _read_password_from_keyring(self, pwdkey):
224 """Physically read from keyring"""
225 keyring = import_keyring()
226 password = keyring.get_password(KEYRING_SERVICE, pwdkey)
227 # Reverse recoding from next routine
228 if isinstance(password, unicode):
229 return encoding.tolocal(password.encode('utf-8'))
230 return password
231
232 def _save_password_to_keyring(self, pwdkey, password):
233 """Physically write to keyring"""
234 keyring = import_keyring()
235 # keyring in general expects unicode.
236 # Mercurial provides "local" encoding. See #33
237 password = encoding.fromlocal(password).decode('utf-8')
238 keyring.set_password(
239 KEYRING_SERVICE, pwdkey, password)
240
241 password_store = PasswordStore()
242
243
244 ############################################################
245 # Tiny utils
246 ############################################################
247
248 def _debug(ui, msg):
249 """Generic debug message"""
250 ui.debug("keyring: " + msg + "\n")
251
252
253 def _debug_reply(ui, msg, url, user, pwd):
254 """Debugging used to note info about data given"""
255 _debug(ui, "%s. Url: %s, user: %s, passwd: %s" % (
256 msg, url, user, pwd and '*' * len(pwd) or 'not set'))
257
258
259 ############################################################
260 # Mercurial modifications
261 ############################################################
262
263
264 class HTTPPasswordHandler(object):
265 """
266 Actual implementation of password handling (user prompting,
267 configuration file searching, keyring save&restore).
268
269 Object of this class is bound as passwordmgr attribute.
270 """
271 def __init__(self):
272 self.pwd_cache = {}
273 self.last_reply = None
274
275 def _return_pwd(self, ui, msg, base_url, realm, authuri, user, req, pwd):
276 """
277 Internal helper. Saves info about auth-data obtained, preserves them in last_reply
278 and returns pair user,pwd
279 """
280 _debug_reply(ui, _(msg), base_url, user, pwd)
281 self.last_reply = dict(realm=realm, authuri=authuri,
282 user=user, req=req)
283 return user, pwd
284
285 def find_auth(self, pwmgr, realm, authuri, req):
286 """
287 Actual implementation of find_user_password - different
288 ways of obtaining the username and password.
289 """
290 ui = pwmgr.ui
291
292 # If we are called again just after identical previous
293 # request, then the previously returned auth must have been
294 # wrong. So we note this to force password prompt (and avoid
295 # reusing bad password indifinitely).
296 after_bad_auth = (self.last_reply
297 and (self.last_reply['realm'] == realm)
298 and (self.last_reply['authuri'] == authuri)
299 and (self.last_reply['req'] == req))
300 if after_bad_auth:
301 _debug(ui, _("Working after bad authentication, cached passwords not used %s") % str(self.last_reply))
302
303 # Strip arguments to get actual remote repository url.
304 base_url = self.canonical_url(authuri)
305
306 # Extracting possible username (or password)
307 # stored directly in repository url
308 user, pwd = urllib2.HTTPPasswordMgrWithDefaultRealm.find_user_password(
309 pwmgr, realm, authuri)
310 if user and pwd:
311 return self._return_pwd(ui, "Auth data present in repository URL", base_url,
312 realm, authuri, user, req, pwd)
313
314 # Loading .hg/hgrc [auth] section contents. If prefix is given,
315 # it will be used as a key to lookup password in the keyring.
316 auth_user, pwd, prefix_url = self.load_hgrc_auth(ui, base_url, user)
317 if prefix_url:
318 keyring_url = prefix_url
319 else:
320 keyring_url = base_url
321 _debug(ui, _("Keyring URL: %s") % keyring_url)
322
323 # Checking the memory cache (there may be many http calls per command)
324 cache_key = (realm, keyring_url)
325 if not after_bad_auth:
326 cached_auth = self.pwd_cache.get(cache_key)
327 if cached_auth:
328 user, pwd = cached_auth
329 return self._return_pwd(ui, "Cached auth data found", base_url,
330 realm, authuri, user, req, pwd)
331
332 if auth_user:
333 if user and (user != auth_user):
334 raise util.Abort(_('mercurial_keyring: username for %s specified both in repository path (%s) and in .hg/hgrc/[auth] (%s). Please, leave only one of those' % (base_url, user, auth_user)))
335 user = auth_user
336 if pwd:
337 self.pwd_cache[cache_key] = user, pwd
338 return self._return_pwd(ui, "Auth data set in .hg/hgrc", base_url,
339 realm, authuri, user, req, pwd)
340 else:
341 _debug(ui, _("Username found in .hg/hgrc: %s") % user)
342
343 # Loading password from keyring.
344 # Only if username is known (so we know the key) and we are
345 # not after failure (so we don't reuse the bad password).
346 if user and not after_bad_auth:
347 _debug(ui, _("Looking for password for user %s and url %s") % (user, keyring_url))
348 pwd = password_store.get_http_password(keyring_url, user)
349 if pwd:
350 self.pwd_cache[cache_key] = user, pwd
351 return self._return_pwd(ui, "Password found in keyring", base_url,
352 realm, authuri, user, req, pwd)
353 else:
354 _debug(ui, _("Password not present in the keyring"))
355
356 # Is the username permanently set?
357 fixed_user = (user and True or False)
358
359 # Last resort: interactive prompt
360 if not ui.interactive():
361 raise util.Abort(_('mercurial_keyring: http authorization required but program used in non-interactive mode'))
362
363 if not fixed_user:
364 ui.status(_("Username not specified in .hg/hgrc. Keyring will not be used.\n"))
365
366 ui.write(_("http authorization required\n"))
367 ui.status(_("realm: %s\n") % realm)
368 if fixed_user:
369 ui.write(_("user: %s (fixed in .hg/hgrc)\n" % user))
370 else:
371 user = ui.prompt(_("user:"), default=None)
372 pwd = ui.getpass(_("password: "))
373
374 if fixed_user:
375 # Saving password to the keyring.
376 # It is done only if username is permanently set.
377 # Otherwise we won't be able to find the password so it
378 # does not make much sense to preserve it
379 _debug(ui, _("Saving password for %s to keyring") % user)
380 password_store.set_http_password(keyring_url, user, pwd)
381
382 # Saving password to the memory cache
383 self.pwd_cache[cache_key] = user, pwd
384
385 return self._return_pwd(ui, "Manually entered password", base_url,
386 realm, authuri, user, req, pwd)
387
388 def load_hgrc_auth(self, ui, base_url, user):
389 """
390 Loading [auth] section contents from local .hgrc
391
392 Returns (username, password, prefix) tuple (every
393 element can be None)
394 """
395 # Theoretically 3 lines below should do:
396 # auth_token = self.readauthtoken(base_url)
397 # if auth_token:
398 # user, pwd = auth.get('username'), auth.get('password')
399 # Unfortunately they do not work, readauthtoken always return
400 # None. Why? Because ui (self.ui of passwordmgr) describes the
401 # *remote* repository, so does *not* contain any option from
402 # local .hg/hgrc.
403
404 # TODO: mercurial 1.4.2 is claimed to resolve this problem
405 # (thanks to: http://hg.xavamedia.nl/mercurial/crew/rev/fb45c1e4396f)
406 # so since this version workaround implemented below should
407 # not be necessary. As it will take some time until people
408 # migrate to >= 1.4.2, it would be best to implement
409 # workaround conditionally.
410
411 # Workaround: we recreate the repository object
412 repo_root = ui.config("bundle", "mainreporoot")
413
414 from mercurial.ui import ui as _ui
415 local_ui = _ui(ui)
416 if repo_root:
417 local_ui.readconfig(os.path.join(repo_root, ".hg", "hgrc"))
418 try:
419 local_passwordmgr = passwordmgr(local_ui)
420 auth_token = local_passwordmgr.readauthtoken(base_url)
421 except AttributeError:
422 try:
423 # hg 1.8
424 import mercurial.url
425 readauthforuri = mercurial.url.readauthforuri
426 except (ImportError, AttributeError):
427 # hg 1.9
428 import mercurial.httpconnection
429 readauthforuri = mercurial.httpconnection.readauthforuri
430 if readauthforuri.func_code.co_argcount == 3:
431 # Since hg.0593e8f81c71
432 res = readauthforuri(local_ui, base_url, user)
433 else:
434 res = readauthforuri(local_ui, base_url)
435 if res:
436 group, auth_token = res
437 else:
438 auth_token = None
439 if auth_token:
440 username = auth_token.get('username')
441 password = auth_token.get('password')
442 prefix = auth_token.get('prefix')
443 shortest_url = self.shortest_url(base_url, prefix)
444 return username, password, shortest_url
445
446 return None, None, None
447
448 def shortest_url(self, base_url, prefix):
449 if not prefix or prefix == '*':
450 return base_url
451 scheme, hostpath = base_url.split('://', 1)
452 p = prefix.split('://', 1)
453 if len(p) > 1:
454 prefix_host_path = p[1]
455 else:
456 prefix_host_path = prefix
457 shortest_url = scheme + '://' + prefix_host_path
458 return shortest_url
459
460 def canonical_url(self, authuri):
461 """
462 Strips query params from url. Used to convert urls like
463 https://repo.machine.com/repos/apps/module?pairs=0000000000000000000000000000000000000000-0000000000000000000000000000000000000000&cmd=between
464 to
465 https://repo.machine.com/repos/apps/module
466 """
467 parsed_url = urlparse(authuri)
468 return "%s://%s%s" % (parsed_url.scheme, parsed_url.netloc,
469 parsed_url.path)
470
471 ############################################################
472 # Monkeypatching
473 ############################################################
474
475
476 @monkeypatch_method(passwordmgr)
477 def find_user_password(self, realm, authuri):
478 """
479 keyring-based implementation of username/password query
480 for HTTP(S) connections
481
482 Passwords are saved in gnome keyring, OSX/Chain or other platform
483 specific storage and keyed by the repository url
484 """
485 # Extend object attributes
486 if not hasattr(self, '_pwd_handler'):
487 self._pwd_handler = HTTPPasswordHandler()
488
489 if hasattr(self, '_http_req'):
490 req = self._http_req
491 else:
492 req = None
493
494 return self._pwd_handler.find_auth(self, realm, authuri, req)
495
496
497 @monkeypatch_method(urllib2.AbstractBasicAuthHandler, "http_error_auth_reqed")
498 def basic_http_error_auth_reqed(self, authreq, host, req, headers):
499 self.passwd._http_req = req
500 try:
501 return basic_http_error_auth_reqed.orig(self, authreq, host, req, headers)
502 finally:
503 self.passwd._http_req = None
504
505
506 @monkeypatch_method(urllib2.AbstractDigestAuthHandler, "http_error_auth_reqed")
507 def digest_http_error_auth_reqed(self, authreq, host, req, headers):
508 self.passwd._http_req = req
509 try:
510 return digest_http_error_auth_reqed.orig(self, authreq, host, req, headers)
511 finally:
512 self.passwd._http_req = None
513
514 ############################################################
515 # SMTP support
516 ############################################################
517
518
519 def try_smtp_login(ui, smtp_obj, username, password):
520 """
521 Attempts smtp login on smtp_obj (smtplib.SMTP) using username and
522 password.
523
524 Returns:
525 - True if login succeeded
526 - False if login failed due to the wrong credentials
527
528 Throws Abort exception if login failed for any other reason.
529
530 Immediately returns False if password is empty
531 """
532 if not password:
533 return False
534 try:
535 ui.note(_('(authenticating to mail server as %s)\n') %
536 (username))
537 smtp_obj.login(username, password)
538 return True
539 except smtplib.SMTPException, inst:
540 if inst.smtp_code == 535:
541 ui.status(_("SMTP login failed: %s\n\n") % inst.smtp_error)
542 return False
543 else:
544 raise util.Abort(inst)
545
546
547 def keyring_supported_smtp(ui, username):
548 """
549 keyring-integrated replacement for mercurial.mail._smtp
550 Used only when configuration file contains username, but
551 does not contain the password.
552
553 Most of the routine below is copied as-is from
554 mercurial.mail._smtp. The only changed part is
555 marked with # >>>>> and # <<<<< markers
556 """
557 local_hostname = ui.config('smtp', 'local_hostname')
558 tls = ui.config('smtp', 'tls', 'none')
559 # backward compatible: when tls = true, we use starttls.
560 starttls = tls == 'starttls' or util.parsebool(tls)
561 smtps = tls == 'smtps'
562 if (starttls or smtps) and not util.safehasattr(socket, 'ssl'):
563 raise util.Abort(_("can't use TLS: Python SSL support not installed"))
564 mailhost = ui.config('smtp', 'host')
565 if not mailhost:
566 raise util.Abort(_('smtp.host not configured - cannot send mail'))
567 verifycert = ui.config('smtp', 'verifycert', 'strict')
568 if verifycert not in ['strict', 'loose']:
569 if util.parsebool(verifycert) is not False:
570 raise util.Abort(_('invalid smtp.verifycert configuration: %s')
571 % (verifycert))
572 verifycert = False
573 if (starttls or smtps) and verifycert:
574 sslkwargs = sslutil.sslkwargs(ui, mailhost)
575 else:
576 sslkwargs = {}
577 if smtps:
578 ui.note(_('(using smtps)\n'))
579 s = SMTPS(sslkwargs, local_hostname=local_hostname)
580 elif starttls:
581 s = STARTTLS(sslkwargs, local_hostname=local_hostname)
582 else:
583 s = smtplib.SMTP(local_hostname=local_hostname)
584 if smtps:
585 defaultport = 465
586 else:
587 defaultport = 25
588 mailport = util.getport(ui.config('smtp', 'port', defaultport))
589 ui.note(_('sending mail: smtp host %s, port %s\n') %
590 (mailhost, mailport))
591 s.connect(host=mailhost, port=mailport)
592 if starttls:
593 ui.note(_('(using starttls)\n'))
594 s.ehlo()
595 s.starttls()
596 s.ehlo()
597 if (starttls or smtps) and verifycert:
598 ui.note(_('(verifying remote certificate)\n'))
599 sslutil.validator(ui, mailhost)(s.sock, verifycert == 'strict')
600
601 # >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
602 stored = password = password_store.get_smtp_password(
603 mailhost, mailport, username)
604 # No need to check whether password was found as try_smtp_login
605 # just returns False if it is absent.
606 while not try_smtp_login(ui, s, username, password):
607 password = ui.getpass(_("Password for %s on %s:%d: ") % (username, mailhost, mailport))
608
609 if stored != password:
610 password_store.set_smtp_password(
611 mailhost, mailport, username, password)
612 # <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
613
614 def send(sender, recipients, msg):
615 try:
616 return s.sendmail(sender, recipients, msg)
617 except smtplib.SMTPRecipientsRefused, inst:
618 recipients = [r[1] for r in inst.recipients.values()]
619 raise util.Abort('\n' + '\n'.join(recipients))
620 except smtplib.SMTPException, inst:
621 raise util.Abort(inst)
622
623 return send
624
625 ############################################################
626 # SMTP monkeypatching
627 ############################################################
628
629
630 @monkeypatch_method(mail)
631 def _smtp(ui):
632 """
633 build an smtp connection and return a function to send email
634
635 This is the monkeypatched version of _smtp(ui) function from
636 mercurial/mail.py. It calls the original unless username
637 without password is given in the configuration.
638 """
639 username = ui.config('smtp', 'username')
640 password = ui.config('smtp', 'password')
641
642 if username and not password:
643 return keyring_supported_smtp(ui, username)
644 else:
645 return _smtp.orig(ui)
General Comments 0
You need to be logged in to leave comments. Login now