##// END OF EJS Templates
Slightly polishing the prefix patch:...
Marcin Kasperski -
r46:e0c8034c default
parent child Browse files
Show More
@@ -1,174 +1,179 b''
1 1 .. -*- mode: rst -*-
2 2
3 3 =================
4 4 mercurial_keyring
5 5 =================
6 6
7 7 ``mercurial_keyring`` is a Mercurial_ extension used to securely save
8 8 HTTP and SMTP authentication passwords in password databases (Gnome
9 9 Keyring, KDE KWallet, OSXKeyChain, specific solutions for Win32 and
10 10 command line). This extension uses and wraps services of the keyring_
11 11 library.
12 12
13 13 .. _keyring: http://pypi.python.org/pypi/keyring
14 14 .. _Mercurial: http://mercurial.selenic.com
15 15
16 16 How does it work
17 17 ================
18 18
19 19 The extension prompts for the password on the first pull/push (in case
20 20 of HTTP) or first email (in case of SMTP), just like it is done by
21 21 default, but saves the password. On successive runs it checks for the
22 22 username in ``.hg/hgrc``, then for suitable password in the password
23 23 database, and uses those credentials (if found).
24 24
25 25 In case password turns out to be incorrect (either because it was
26 26 invalid, or because it was changed on the server) or missing it just
27 27 prompts the user again.
28 28
29 29 Passwords are identified by the combination of username and remote
30 repository url (for HTTP) or username and smtp server address (for
31 SMTP), so they can be reused between repositories if they access
32 the same remote repository.
30 address, so they can be reused between repositories if they access the
31 same remote repository (or the same SMTP server).
33 32
34 33 Installation
35 34 ============
36 35
37 36 Prerequisites
38 37 -------------
39 38
40 39 Install the keyring_ library:
41 40
42 41 ::
43 42
44 43 easy_install keyring
45 44
46 45 (or ``pip keyring``). On Debian "Sid" the library can be also
47 46 installed from the official archive (packages ``python-keyring``
48 47 and either ``python-keyring-gnome`` or ``python-keyring-kwallet``).
49 48
50 49 Extension installation
51 50 ----------------------
52 51
53 52 There are two possible ways of installing the extension: using PyPi package,
54 53 or using individual file.
55 54
56 55 To install as a package use ``easy_install``:
57 56
58 57 ::
59 58
60 59 easy_install mercurial_keyring
61 60
62 61 and then enable it in ``~/.hgrc`` (or ``/etc/mercurial/hgrc``) using:
63 62
64 63 ::
65 64
66 65 [extensions]
67 66 mercurial_keyring =
68 67
69 68 To install using individual file, download the
70 69 `mercurial_keyring.py`_ file, save it anywhere you like, and
71 70 put the following in ``~/.hgrc`` (or ``/etc/mercurial/hgrc``):
72 71
73 72 ::
74 73
75 74 [extensions]
76 75 hgext.mercurial_keyring = /path/to/mercurial_keyring.py
77 76
78 77 .. _the code:
79 78 .. _mercurial_keyring.py: http://bitbucket.org/Mekk/mercurial_keyring/src/tip/mercurial_keyring.py
80 79
81 80 Password backend configuration
82 81 ==============================
83 82
84 83 The library should usually pick the most appropriate password backend
85 84 without configuration. Still, if necessary, it can be configured using
86 85 ``~/keyringrc.cfg`` file (``keyringrc.cfg`` in the home directory of
87 86 the current user). Refer to keyring_ docs for more details.
88 87
89 88 *I considered handling similar options in hgrc, but decided that
90 89 single user may use more than one keyring-based script. Still, I am
91 90 open to suggestions.*
92 91
93 92 Repository configuration (HTTP)
94 93 ===============================
95 94
96 95 Edit repository-local ``.hg/hgrc`` and save there the remote
97 96 repository path and the username, but do not save the password. For
98 97 example:
99 98
100 99 ::
101 100
102 101 [paths]
103 102 myremote = https://my.server.com/hgrepo/someproject
104 103
105 104 [auth]
106 105 myremote.schemes = http https
107 106 myremote.prefix = my.server.com/hgrepo
108 107 myremote.username = mekk
109 108
110 109 Simpler form with url-embedded name can also be used:
111 110
112 111 ::
113 112
114 113 [paths]
115 114 bitbucket = https://User@bitbucket.org/User/project_name/
116 115
116 If prefix is specified, it is used to identify the password (so all
117 repositories with the same prefix and the same username will share the
118 same password). Otherwise full repository URL is used for this
119 purpose.
120
117 121 Note: if both username and password are given in ``.hg/hgrc``,
118 122 extension will use them without using the password database. If
119 123 username is not given, extension will prompt for credentials every
120 124 time, also without saving the password.
121 125
122 126 Repository configuration (SMTP)
123 127 ===============================
124 128
125 129 Edit either repository-local ``.hg/hgrc``, or ``~/.hgrc`` and set
126 130 there all standard email and smtp properties, including SMTP
127 131 username, but without SMTP password. For example:
128 132
129 133 ::
130 134
131 135 [email]
132 136 method = smtp
133 137 from = Joe Doe <Joe.Doe@remote.com>
134 138
135 139 [smtp]
136 140 host = smtp.gmail.com
137 141 port = 587
138 142 username = JoeDoe@gmail.com
139 143 tls = true
140 144
141 145 Just as in case of HTTP, you *must* set username, but *must not* set
142 146 password here to use the extension, in other cases it will revert to
143 147 the default behavior.
144 148
145 149 Usage
146 150 =====
147 151
148 152 Configure the repository as above, then just ``hg pull``, ``hg push``,
149 153 etc. You should be asked for the password only once (per every
150 username+remote_repository_url combination).
154 username and remote repository prefix or url combination).
155
151 156
152 157 Similarly, for email, configure as above and just ``hg email``.
153 Again, you will be asked for the password once (per every
154 username+email_server_name+email_server_port).
158 Again, you will be asked for the password once (per every username and
159 email server address combination).
155 160
156 161 Implementation details
157 162 ======================
158 163
159 164 The extension is monkey-patching the mercurial ``passwordmgr`` class
160 165 to replace the find_user_password method. Detailed order of operations
161 166 is described in the comments inside `the code`_.
162 167
163 168 Development
164 169 ===========
165 170
166 171 Development is tracked on BitBucket, see
167 172 http://bitbucket.org/Mekk/mercurial_keyring/
168 173
169 174
170 175 Additional notes
171 176 ================
172 177
173 178 Information about this extension is also available
174 179 on Mercurial Wiki: http://mercurial.selenic.com/wiki/KeyringExtension
@@ -1,366 +1,369 b''
1 1 # -*- coding: utf-8 -*-
2 2 #
3 3 # mercurial_keyring: save passwords in password database
4 4 #
5 5 # Copyright 2009 Marcin Kasperski <Marcin.Kasperski@mekk.waw.pl>
6 6 #
7 7 # This software may be used and distributed according to the terms
8 8 # of the GNU General Public License, incorporated herein by reference.
9 9 #
10 10 # See README.txt for more details.
11 11
12 12 from mercurial import hg, repo, util
13 13 from mercurial.i18n import _
14 14 try:
15 15 from mercurial.url import passwordmgr
16 16 except:
17 17 from mercurial.httprepo import passwordmgr
18 18 from mercurial.httprepo import httprepository
19 19 from mercurial import mail
20 20
21 21 import keyring
22 22 from urlparse import urlparse
23 23 import urllib2
24 24 import smtplib, socket
25 import os
25 26
26 27 KEYRING_SERVICE = "Mercurial"
27 28
28 29 ############################################################
29 30
30 31 def monkeypatch_method(cls):
31 32 def decorator(func):
32 33 setattr(cls, func.__name__, func)
33 34 return func
34 35 return decorator
35 36
36 37 ############################################################
37 38
38 39 class PasswordStore(object):
39 40 """
40 41 Helper object handling keyring usage (password save&restore,
41 42 the way passwords are keyed in the keyring).
42 43 """
43 44 def __init__(self):
44 45 self.cache = dict()
45 46 def get_http_password(self, url, username):
46 47 return keyring.get_password(KEYRING_SERVICE,
47 48 self._format_http_key(url, username))
48 49 def set_http_password(self, url, username, password):
49 50 keyring.set_password(KEYRING_SERVICE,
50 51 self._format_http_key(url, username),
51 52 password)
52 53 def clear_http_password(self, url, username):
53 54 self.set_http_password(url, username, "")
54 55 def _format_http_key(self, url, username):
55 56 return "%s@@%s" % (username, url)
56 57 def get_smtp_password(self, machine, port, username):
57 58 return keyring.get_password(
58 59 KEYRING_SERVICE,
59 60 self._format_smtp_key(machine, port, username))
60 61 def set_smtp_password(self, machine, port, username, password):
61 62 keyring.set_password(
62 63 KEYRING_SERVICE,
63 64 self._format_smtp_key(machine, port, username),
64 65 password)
65 66 def clear_smtp_password(self, machine, port, username):
66 67 self.set_smtp_password(url, username, "")
67 68 def _format_smtp_key(self, machine, port, username):
68 69 return "%s@@%s:%s" % (username, machine, str(port))
69 70
70 71 password_store = PasswordStore()
71 72
72 73 ############################################################
73 74
74 75 class HTTPPasswordHandler(object):
75 76 """
76 77 Actual implementation of password handling (user prompting,
77 78 configuration file searching, keyring save&restore).
78 79
79 80 Object of this class is bound as passwordmgr attribute.
80 81 """
81 82 def __init__(self):
82 83 self.pwd_cache = {}
83 84 self.last_reply = None
84 85
85 86 def find_auth(self, pwmgr, realm, authuri):
86 87 """
87 88 Actual implementation of find_user_password - different
88 89 ways of obtaining the username and password.
89 90 """
90 91 ui = pwmgr.ui
91 92
92 93 # If we are called again just after identical previous
93 94 # request, then the previously returned auth must have been
94 95 # wrong. So we note this to force password prompt (and avoid
95 96 # reusing bad password indifinitely).
96 97 after_bad_auth = (self.last_reply \
97 98 and (self.last_reply['realm'] == realm) \
98 99 and (self.last_reply['authuri'] == authuri))
99 100
100 101 # Strip arguments to get actual remote repository url.
101 102 base_url = self.canonical_url(authuri)
102 103
103 104 # Extracting possible username (or password)
104 105 # stored directly in repository url
105 106 user, pwd = urllib2.HTTPPasswordMgrWithDefaultRealm.find_user_password(
106 107 pwmgr, realm, authuri)
107 108 if user and pwd:
108 109 self._debug_reply(ui, _("Auth data found in repository URL"),
109 110 base_url, user, pwd)
110 111 self.last_reply = dict(realm=realm,authuri=authuri,user=user)
111 112 return user, pwd
112 113
113 # Loading username and maybe password and prefix URL from [auth] in .hg/hgrc
114 nuser, pwd, prefix_url = self.load_hgrc_auth(ui, base_url)
114 # Loading .hg/hgrc [auth] section contents. If prefix is given,
115 # it will be used as a key to lookup password in the keyring.
116 auth_user, pwd, prefix_url = self.load_hgrc_auth(ui, base_url)
115 117 if prefix_url:
116 118 keyring_url = prefix_url
117 119 else:
118 120 keyring_url = base_url
119 121 ui.debug("keyring URL: %s\n" % keyring_url)
120 122
121 123 # Checking the memory cache (there may be many http calls per command)
122 124 cache_key = (realm, keyring_url)
123 125 if not after_bad_auth:
124 126 cached_auth = self.pwd_cache.get(cache_key)
125 127 if cached_auth:
126 128 user, pwd = cached_auth
127 129 self._debug_reply(ui, _("Cached auth data found"),
128 130 base_url, user, pwd)
129 131 self.last_reply = dict(realm=realm,authuri=authuri,user=user)
130 132 return user, pwd
131 133
132 if nuser:
134 if auth_user:
133 135 if user:
134 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, nuser)))
135 user = nuser
136 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)))
137 user = auth_user
136 138 if pwd:
137 139 self.pwd_cache[cache_key] = user, pwd
138 140 self._debug_reply(ui, _("Auth data set in .hg/hgrc"),
139 141 base_url, user, pwd)
140 142 self.last_reply = dict(realm=realm,authuri=authuri,user=user)
141 143 return user, pwd
142 144 else:
143 145 ui.debug(_("Username found in .hg/hgrc: %s\n" % user))
144 146
145 147 # Loading password from keyring.
146 148 # Only if username is known (so we know the key) and we are
147 149 # not after failure (so we don't reuse the bad password).
148 150 if user and not after_bad_auth:
149 151 pwd = password_store.get_http_password(keyring_url, user)
150 152 if pwd:
151 153 self.pwd_cache[cache_key] = user, pwd
152 154 self._debug_reply(ui, _("Keyring password found"),
153 155 base_url, user, pwd)
154 156 self.last_reply = dict(realm=realm,authuri=authuri,user=user)
155 157 return user, pwd
156 158
157 159 # Is the username permanently set?
158 160 fixed_user = (user and True or False)
159 161
160 162 # Last resort: interactive prompt
161 163 if not ui.interactive():
162 164 raise util.Abort(_('mercurial_keyring: http authorization required but program used in non-interactive mode'))
163 165 ui.write(_("http authorization required\n"))
164 166 ui.status(_("realm: %s\n") % realm)
165 167 if fixed_user:
166 168 ui.write(_("user: %s (fixed in .hg/hgrc)\n" % user))
167 169 else:
168 170 user = ui.prompt(_("user:"), default=None)
169 171 pwd = ui.getpass(_("password: "))
170 172
171 173 if fixed_user:
172 174 # Saving password to the keyring.
173 175 # It is done only if username is permanently set.
174 176 # Otherwise we won't be able to find the password so it
175 177 # does not make much sense to preserve it
176 178 ui.debug("Saving password for %s to keyring\n" % user)
177 179 password_store.set_http_password(keyring_url, user, pwd)
178 180
179 181 # Saving password to the memory cache
180 182 self.pwd_cache[cache_key] = user, pwd
181 183
182 184 self._debug_reply(ui, _("Manually entered password"),
183 185 base_url, user, pwd)
184 186 self.last_reply = dict(realm=realm,authuri=authuri,user=user)
185 187 return user, pwd
186 188
187 189 def load_hgrc_auth(self, ui, base_url):
188 190 """
189 Loading username and possibly password from [auth] in local
190 repo .hgrc
191 Loading [auth] section contents from local .hgrc
192
193 Returns (username, password, prefix) tuple (every
194 element can be None)
191 195 """
192 196 # Theoretically 3 lines below should do:
193 197
194 198 #auth_token = self.readauthtoken(base_url)
195 199 #if auth_token:
196 200 # user, pwd = auth.get('username'), auth.get('password')
197 201
198 202 # Unfortunately they do not work, readauthtoken always return
199 203 # None. Why? Because ui (self.ui of passwordmgr) describes the
200 204 # *remote* repository, so does *not* contain any option from
201 205 # local .hg/hgrc.
202 206
203 207 # Workaround: we recreate the repository object
204 208 repo_root = ui.config("bundle", "mainreporoot")
205 209
206 210 from mercurial.ui import ui as _ui
207 import os
208 211 local_ui = _ui(ui)
209 212 if repo_root:
210 213 local_ui.readconfig(os.path.join(repo_root, ".hg", "hgrc"))
211 214 local_passwordmgr = passwordmgr(local_ui)
212 215 auth_token = local_passwordmgr.readauthtoken(base_url)
213 216 if auth_token:
214 217 username = auth_token.get('username')
215 218 password = auth_token.get('password')
216 219 prefix = auth_token.get('prefix')
217 220 shortest_url = self.shortest_url(base_url, prefix)
218 221 return username, password, shortest_url
219 222
220 return None, None
223 return None, None, None
221 224
222 225 def shortest_url(self, base_url, prefix):
223 226 if not prefix or prefix == '*':
224 227 return base_url
225 228 scheme, hostpath = base_url.split('://', 1)
226 229 p = prefix.split('://', 1)
227 230 if len(p) > 1:
228 231 prefix_host_path = p[1]
229 232 else:
230 233 prefix_host_path = prefix
231 234 shortest_url = scheme + '://' + prefix_host_path
232 235 return shortest_url
233 236
234 237 def canonical_url(self, authuri):
235 238 """
236 239 Strips query params from url. Used to convert urls like
237 240 https://repo.machine.com/repos/apps/module?pairs=0000000000000000000000000000000000000000-0000000000000000000000000000000000000000&cmd=between
238 241 to
239 242 https://repo.machine.com/repos/apps/module
240 243 """
241 244 parsed_url = urlparse(authuri)
242 245 return "%s://%s%s" % (parsed_url.scheme, parsed_url.netloc,
243 246 parsed_url.path)
244 247
245 248 def _debug_reply(self, ui, msg, url, user, pwd):
246 249 ui.debug("%s. Url: %s, user: %s, passwd: %s\n" % (
247 250 msg, url, user, pwd and '*' * len(pwd) or 'not set'))
248 251
249 252 ############################################################
250 253
251 254 @monkeypatch_method(passwordmgr)
252 255 def find_user_password(self, realm, authuri):
253 256 """
254 257 keyring-based implementation of username/password query
255 258 for HTTP(S) connections
256 259
257 260 Passwords are saved in gnome keyring, OSX/Chain or other platform
258 261 specific storage and keyed by the repository url
259 262 """
260 263 # Extend object attributes
261 264 if not hasattr(self, '_pwd_handler'):
262 265 self._pwd_handler = HTTPPasswordHandler()
263 266
264 267 return self._pwd_handler.find_auth(self, realm, authuri)
265 268
266 269 ############################################################
267 270
268 271 def try_smtp_login(ui, smtp_obj, username, password):
269 272 """
270 273 Attempts smtp login on smtp_obj (smtplib.SMTP) using username and
271 274 password.
272 275
273 276 Returns:
274 277 - True if login succeeded
275 278 - False if login failed due to the wrong credentials
276 279
277 280 Throws Abort exception if login failed for any other reason.
278 281
279 282 Immediately returns False if password is empty
280 283 """
281 284 if not password:
282 285 return False
283 286 try:
284 287 ui.note(_('(authenticating to mail server as %s)\n') %
285 288 (username))
286 289 smtp_obj.login(username, password)
287 290 return True
288 291 except smtplib.SMTPException, inst:
289 292 if inst.smtp_code == 535:
290 293 ui.status(_("SMTP login failed: %s\n\n") % inst.smtp_error)
291 294 return False
292 295 else:
293 296 raise util.Abort(inst)
294 297
295 298 def keyring_supported_smtp(ui, username):
296 299 """
297 300 keyring-integrated replacement for mercurial.mail._smtp
298 301 Used only when configuration file contains username, but
299 302 does not contain the password.
300 303
301 304 Most of the routine below is copied as-is from
302 305 mercurial.mail._smtp. The only changed part is
303 306 marked with #>>>>> and #<<<<< markers
304 307 """
305 308 local_hostname = ui.config('smtp', 'local_hostname')
306 309 s = smtplib.SMTP(local_hostname=local_hostname)
307 310 mailhost = ui.config('smtp', 'host')
308 311 if not mailhost:
309 312 raise util.Abort(_('no [smtp]host in hgrc - cannot send mail'))
310 313 mailport = int(ui.config('smtp', 'port', 25))
311 314 ui.note(_('sending mail: smtp host %s, port %s\n') %
312 315 (mailhost, mailport))
313 316 s.connect(host=mailhost, port=mailport)
314 317 if ui.configbool('smtp', 'tls'):
315 318 if not hasattr(socket, 'ssl'):
316 319 raise util.Abort(_("can't use TLS: Python SSL support "
317 320 "not installed"))
318 321 ui.note(_('(using tls)\n'))
319 322 s.ehlo()
320 323 s.starttls()
321 324 s.ehlo()
322 325
323 326 #>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
324 327 stored = password = password_store.get_smtp_password(
325 328 mailhost, mailport, username)
326 329 # No need to check whether password was found as try_smtp_login
327 330 # just returns False if it is absent.
328 331 while not try_smtp_login(ui, s, username, password):
329 332 password = ui.getpass(_("Password for %s on %s:%d: ") % (username, mailhost, mailport))
330 333
331 334 if stored != password:
332 335 password_store.set_smtp_password(
333 336 mailhost, mailport, username, password)
334 337 #<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
335 338
336 339 def send(sender, recipients, msg):
337 340 try:
338 341 return s.sendmail(sender, recipients, msg)
339 342 except smtplib.SMTPRecipientsRefused, inst:
340 343 recipients = [r[1] for r in inst.recipients.values()]
341 344 raise util.Abort('\n' + '\n'.join(recipients))
342 345 except smtplib.SMTPException, inst:
343 346 raise util.Abort(inst)
344 347
345 348 return send
346 349
347 350 ############################################################
348 351
349 352 orig_smtp = mail._smtp
350 353
351 354 @monkeypatch_method(mail)
352 355 def _smtp(ui):
353 356 """
354 357 build an smtp connection and return a function to send email
355 358
356 359 This is the monkeypatched version of _smtp(ui) function from
357 360 mercurial/mail.py. It calls the original unless username
358 361 without password is given in the configuration.
359 362 """
360 363 username = ui.config('smtp', 'username')
361 364 password = ui.config('smtp', 'password')
362 365
363 366 if username and not password:
364 367 return keyring_supported_smtp(ui, username)
365 368 else:
366 369 return orig_smtp(ui)
General Comments 0
You need to be logged in to leave comments. Login now