eric6/Helpviewer/Passwords/PasswordManager.py

branch
maintenance
changeset 7286
7eb04391adf7
parent 7226
babe80d84a3e
parent 7285
1ff497f33f31
child 7287
1c17f2191bdd
equal deleted inserted replaced
7226:babe80d84a3e 7286:7eb04391adf7
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2009 - 2019 Detlev Offenbach <detlev@die-offenbachs.de>
4 #
5
6 """
7 Module implementing the password manager.
8 """
9
10 from __future__ import unicode_literals
11
12 import os
13
14 from PyQt5.QtCore import pyqtSignal, QObject, QByteArray, QUrl, \
15 QCoreApplication, QXmlStreamReader
16 from PyQt5.QtWidgets import QApplication
17 from PyQt5.QtNetwork import QNetworkRequest
18 from PyQt5.QtWebKit import QWebSettings
19 from PyQt5.QtWebKitWidgets import QWebPage
20
21 from E5Gui import E5MessageBox
22 from E5Gui.E5ProgressDialog import E5ProgressDialog
23
24 from Utilities.AutoSaver import AutoSaver
25 import Utilities
26 import Utilities.crypto
27 import Preferences
28 from Globals import qVersionTuple
29
30
31 class PasswordManager(QObject):
32 """
33 Class implementing the password manager.
34
35 @signal changed() emitted to indicate a change
36 @signal passwordsSaved() emitted after the passwords were saved
37 """
38 changed = pyqtSignal()
39 passwordsSaved = pyqtSignal()
40
41 SEPARATOR = "===================="
42 FORMS = "=====FORMS====="
43 NEVER = "=====NEVER====="
44
45 def __init__(self, parent=None):
46 """
47 Constructor
48
49 @param parent reference to the parent object (QObject)
50 """
51 super(PasswordManager, self).__init__(parent)
52
53 self.__logins = {}
54 self.__loginForms = {}
55 self.__never = []
56 self.__loaded = False
57 self.__saveTimer = AutoSaver(self, self.save)
58
59 self.changed.connect(self.__saveTimer.changeOccurred)
60
61 def clear(self):
62 """
63 Public slot to clear the saved passwords.
64 """
65 if not self.__loaded:
66 self.__load()
67
68 self.__logins = {}
69 self.__loginForms = {}
70 self.__never = []
71 self.__saveTimer.changeOccurred()
72 self.__saveTimer.saveIfNeccessary()
73
74 self.changed.emit()
75
76 def getLogin(self, url, realm):
77 """
78 Public method to get the login credentials.
79
80 @param url URL to get the credentials for (QUrl)
81 @param realm realm to get the credentials for (string)
82 @return tuple containing the user name (string) and password (string)
83 """
84 if not self.__loaded:
85 self.__load()
86
87 key = self.__createKey(url, realm)
88 try:
89 return self.__logins[key][0], Utilities.crypto.pwConvert(
90 self.__logins[key][1], encode=False)
91 except KeyError:
92 return "", ""
93
94 def setLogin(self, url, realm, username, password):
95 """
96 Public method to set the login credentials.
97
98 @param url URL to set the credentials for (QUrl)
99 @param realm realm to set the credentials for (string)
100 @param username username for the login (string)
101 @param password password for the login (string)
102 """
103 if not self.__loaded:
104 self.__load()
105
106 key = self.__createKey(url, realm)
107 self.__logins[key] = (
108 username,
109 Utilities.crypto.pwConvert(password, encode=True)
110 )
111 self.changed.emit()
112
113 def __createKey(self, url, realm):
114 """
115 Private method to create the key string for the login credentials.
116
117 @param url URL to get the credentials for (QUrl)
118 @param realm realm to get the credentials for (string)
119 @return key string (string)
120 """
121 authority = url.authority()
122 if authority.startswith("@"):
123 authority = authority[1:]
124 if realm:
125 key = "{0}://{1} ({2})".format(
126 url.scheme(), authority, realm)
127 else:
128 key = "{0}://{1}".format(url.scheme(), authority)
129 return key
130
131 def getFileName(self):
132 """
133 Public method to get the file name of the passwords file.
134
135 @return name of the passwords file (string)
136 """
137 return os.path.join(Utilities.getConfigDir(), "browser", "logins.xml")
138
139 def save(self):
140 """
141 Public slot to save the login entries to disk.
142 """
143 if not self.__loaded:
144 return
145
146 from .PasswordWriter import PasswordWriter
147 loginFile = self.getFileName()
148 writer = PasswordWriter()
149 if not writer.write(
150 loginFile, self.__logins, self.__loginForms, self.__never):
151 E5MessageBox.critical(
152 None,
153 self.tr("Saving login data"),
154 self.tr(
155 """<p>Login data could not be saved to <b>{0}</b></p>"""
156 ).format(loginFile))
157 else:
158 self.passwordsSaved.emit()
159
160 def __load(self):
161 """
162 Private method to load the saved login credentials.
163 """
164 loginFile = self.getFileName()
165 if not os.path.exists(loginFile):
166 self.__loadNonXml(os.path.splitext(loginFile)[0])
167 else:
168 from .PasswordReader import PasswordReader
169 reader = PasswordReader()
170 self.__logins, self.__loginForms, self.__never = \
171 reader.read(loginFile)
172 if reader.error() != QXmlStreamReader.NoError:
173 E5MessageBox.warning(
174 None,
175 self.tr("Loading login data"),
176 self.tr("""Error when loading login data on"""
177 """ line {0}, column {1}:\n{2}""")
178 .format(reader.lineNumber(),
179 reader.columnNumber(),
180 reader.errorString()))
181
182 self.__loaded = True
183
184 def __loadNonXml(self, loginFile):
185 """
186 Private method to load non-XML password files.
187
188 This method is to convert from the old, non-XML format to the new
189 XML based format.
190
191 @param loginFile name of the non-XML password file (string)
192 """
193 if os.path.exists(loginFile):
194 try:
195 f = open(loginFile, "r", encoding="utf-8")
196 lines = f.read()
197 f.close()
198 except IOError as err:
199 E5MessageBox.critical(
200 None,
201 self.tr("Loading login data"),
202 self.tr("""<p>Login data could not be loaded """
203 """from <b>{0}</b></p>"""
204 """<p>Reason: {1}</p>""")
205 .format(loginFile, str(err)))
206 return
207
208 data = []
209 section = 0
210 # 0 = login data, 1 = forms data, 2 = never store info
211 for line in lines.splitlines():
212 if line == self.FORMS:
213 section = 1
214 continue
215 elif line == self.NEVER:
216 section = 2
217 continue
218
219 if section == 0:
220 if line != self.SEPARATOR:
221 data.append(line)
222 else:
223 if len(data) != 3:
224 E5MessageBox.critical(
225 None,
226 self.tr("Loading login data"),
227 self.tr(
228 """<p>Login data could not be loaded """
229 """from <b>{0}</b></p>"""
230 """<p>Reason: Wrong input format</p>""")
231 .format(loginFile))
232 return
233 self.__logins[data[0]] = (data[1], data[2])
234 data = []
235
236 elif section == 1:
237 if line != self.SEPARATOR:
238 data.append(line)
239 else:
240 from .LoginForm import LoginForm
241 key = data[0]
242 form = LoginForm()
243 form.url = QUrl(data[1])
244 form.name = data[2]
245 form.hasAPassword = data[3] == "True"
246 for element in data[4:]:
247 name, value = element.split(" = ", 1)
248 form.elements.append((name, value))
249 self.__loginForms[key] = form
250 data = []
251
252 elif section == 2:
253 self.__never.append(line)
254
255 os.remove(loginFile)
256
257 self.__loaded = True
258
259 # this does the conversion
260 self.save()
261
262 def reload(self):
263 """
264 Public method to reload the login data.
265 """
266 if not self.__loaded:
267 return
268
269 self.__load()
270
271 def close(self):
272 """
273 Public method to close the passwords manager.
274 """
275 self.__saveTimer.saveIfNeccessary()
276
277 def removePassword(self, site):
278 """
279 Public method to remove a password entry.
280
281 @param site web site name (string)
282 """
283 if site in self.__logins:
284 del self.__logins[site]
285 if site in self.__loginForms:
286 del self.__loginForms[site]
287 self.changed.emit()
288
289 def allSiteNames(self):
290 """
291 Public method to get a list of all site names.
292
293 @return sorted list of all site names (list of strings)
294 """
295 if not self.__loaded:
296 self.__load()
297
298 return sorted(self.__logins.keys())
299
300 def sitesCount(self):
301 """
302 Public method to get the number of available sites.
303
304 @return number of sites (integer)
305 """
306 if not self.__loaded:
307 self.__load()
308
309 return len(self.__logins)
310
311 def siteInfo(self, site):
312 """
313 Public method to get a reference to the named site.
314
315 @param site web site name (string)
316 @return tuple containing the user name (string) and password (string)
317 """
318 if not self.__loaded:
319 self.__load()
320
321 if site not in self.__logins:
322 return None
323
324 return self.__logins[site][0], Utilities.crypto.pwConvert(
325 self.__logins[site][1], encode=False)
326
327 def post(self, request, data):
328 """
329 Public method to check, if the data to be sent contains login data.
330
331 @param request reference to the network request (QNetworkRequest)
332 @param data data to be sent (QByteArray)
333 """
334 # shall passwords be saved?
335 if not Preferences.getUser("SavePasswords"):
336 return
337
338 # observe privacy
339 if QWebSettings.globalSettings().testAttribute(
340 QWebSettings.PrivateBrowsingEnabled):
341 return
342
343 if not self.__loaded:
344 self.__load()
345
346 # determine the url
347 refererHeader = request.rawHeader(b"Referer")
348 if refererHeader.isEmpty():
349 return
350 url = QUrl.fromEncoded(refererHeader)
351 url = self.__stripUrl(url)
352
353 # check that url isn't in __never
354 if url.toString() in self.__never:
355 return
356
357 # check the request type
358 navType = request.attribute(QNetworkRequest.User + 101)
359 if navType is None:
360 return
361 if navType != QWebPage.NavigationTypeFormSubmitted:
362 return
363
364 # determine the QWebPage
365 webPage = request.attribute(QNetworkRequest.User + 100)
366 if webPage is None:
367 return
368
369 # determine the requests content type
370 contentTypeHeader = request.rawHeader(b"Content-Type")
371 if contentTypeHeader.isEmpty():
372 return
373 multipart = contentTypeHeader.startsWith(b"multipart/form-data")
374 if multipart:
375 boundary = contentTypeHeader.split(" ")[1].split("=")[1]
376 else:
377 boundary = None
378
379 # find the matching form on the web page
380 form = self.__findForm(webPage, data, boundary=boundary)
381 if not form.isValid():
382 return
383 form.url = QUrl(url)
384
385 # check, if the form has a password
386 if not form.hasAPassword:
387 return
388
389 # prompt, if the form has never be seen
390 key = self.__createKey(url, "")
391 if key not in self.__loginForms:
392 mb = E5MessageBox.E5MessageBox(
393 E5MessageBox.Question,
394 self.tr("Save password"),
395 self.tr(
396 """<b>Would you like to save this password?</b><br/>"""
397 """To review passwords you have saved and remove them, """
398 """use the password management dialog of the Settings"""
399 """ menu."""),
400 modal=True)
401 neverButton = mb.addButton(
402 self.tr("Never for this site"),
403 E5MessageBox.DestructiveRole)
404 noButton = mb.addButton(
405 self.tr("Not now"), E5MessageBox.RejectRole)
406 mb.addButton(E5MessageBox.Yes)
407 mb.exec_()
408 if mb.clickedButton() == neverButton:
409 self.__never.append(url.toString())
410 return
411 elif mb.clickedButton() == noButton:
412 return
413
414 # extract user name and password
415 user = ""
416 password = ""
417 for index in range(len(form.elements)):
418 element = form.elements[index]
419 type_ = form.elementTypes[element[0]]
420 if user == "" and \
421 type_ == "text":
422 user = element[1]
423 elif password == "" and \
424 type_ == "password":
425 password = element[1]
426 form.elements[index] = (element[0], "--PASSWORD--")
427 if user and password:
428 self.__logins[key] = \
429 (user, Utilities.crypto.pwConvert(password, encode=True))
430 self.__loginForms[key] = form
431 self.changed.emit()
432
433 def __stripUrl(self, url):
434 """
435 Private method to strip off all unneeded parts of a URL.
436
437 @param url URL to be stripped (QUrl)
438 @return stripped URL (QUrl)
439 """
440 cleanUrl = QUrl(url)
441 if qVersionTuple() >= (5, 0, 0):
442 cleanUrl.setQuery("")
443 else:
444 cleanUrl.setQueryItems([])
445 cleanUrl.setUserInfo("")
446
447 authority = cleanUrl.authority()
448 if authority.startswith("@"):
449 authority = authority[1:]
450 cleanUrl = QUrl("{0}://{1}{2}".format(
451 cleanUrl.scheme(), authority, cleanUrl.path()))
452 cleanUrl.setFragment("")
453 return cleanUrl
454
455 def __findForm(self, webPage, data, boundary=None):
456 """
457 Private method to find the form used for logging in.
458
459 @param webPage reference to the web page (QWebPage)
460 @param data data to be sent (QByteArray)
461 @keyparam boundary boundary string (QByteArray) for multipart
462 encoded data, None for urlencoded data
463 @return parsed form (LoginForm)
464 """
465 from .LoginForm import LoginForm
466 form = LoginForm()
467 if boundary is not None:
468 args = self.__extractMultipartQueryItems(data, boundary)
469 else:
470 if qVersionTuple() >= (5, 0, 0):
471 from PyQt5.QtCore import QUrlQuery
472 argsUrl = QUrl.fromEncoded(
473 QByteArray(b"foo://bar.com/?" + QUrl.fromPercentEncoding(
474 data.replace(b"+", b"%20")).encode("utf-8")))
475 encodedArgs = QUrlQuery(argsUrl).queryItems()
476 else:
477 argsUrl = QUrl.fromEncoded(
478 QByteArray(b"foo://bar.com/?" + data.replace(b"+", b"%20"))
479 )
480 encodedArgs = argsUrl.queryItems()
481 args = set()
482 for arg in encodedArgs:
483 key = arg[0]
484 value = arg[1]
485 args.add((key, value))
486
487 # extract the forms
488 from Helpviewer.JavaScriptResources import parseForms_js
489 lst = webPage.mainFrame().evaluateJavaScript(parseForms_js)
490 for formMap in lst:
491 formHasPasswords = False
492 formName = formMap["name"]
493 formIndex = formMap["index"]
494 if isinstance(formIndex, float) and formIndex.is_integer():
495 formIndex = int(formIndex)
496 elements = formMap["elements"]
497 formElements = set()
498 formElementTypes = {}
499 deadElements = set()
500 for elementMap in elements:
501 try:
502 name = elementMap["name"]
503 value = elementMap["value"]
504 type_ = elementMap["type"]
505 except KeyError:
506 continue
507 if type_ == "password":
508 formHasPasswords = True
509 t = (name, value)
510 try:
511 if elementMap["autocomplete"] == "off":
512 deadElements.add(t)
513 except KeyError:
514 pass
515 if name:
516 formElements.add(t)
517 formElementTypes[name] = type_
518 if formElements.intersection(args) == args:
519 form.hasAPassword = formHasPasswords
520 if not formName:
521 form.name = formIndex
522 else:
523 form.name = formName
524 args.difference_update(deadElements)
525 for elt in deadElements:
526 if elt[0] in formElementTypes:
527 del formElementTypes[elt[0]]
528 form.elements = list(args)
529 form.elementTypes = formElementTypes
530 break
531
532 return form
533
534 def __extractMultipartQueryItems(self, data, boundary):
535 """
536 Private method to extract the query items for a post operation.
537
538 @param data data to be sent (QByteArray)
539 @param boundary boundary string (QByteArray)
540 @return set of name, value pairs (set of tuple of string, string)
541 """
542 args = set()
543
544 dataStr = bytes(data).decode()
545 boundaryStr = bytes(boundary).decode()
546
547 parts = dataStr.split(boundaryStr + "\r\n")
548 for part in parts:
549 if part.startswith("Content-Disposition"):
550 lines = part.split("\r\n")
551 name = lines[0].split("=")[1][1:-1]
552 value = lines[2]
553 args.add((name, value))
554
555 return args
556
557 def fill(self, page):
558 """
559 Public slot to fill login forms with saved data.
560
561 @param page reference to the web page (QWebPage)
562 """
563 if page is None or page.mainFrame() is None:
564 return
565
566 if not self.__loaded:
567 self.__load()
568
569 url = page.mainFrame().url()
570 url = self.__stripUrl(url)
571 key = self.__createKey(url, "")
572 if key not in self.__loginForms or \
573 key not in self.__logins:
574 return
575
576 form = self.__loginForms[key]
577 if form.url != url:
578 return
579
580 if form.name == "":
581 formName = "0"
582 else:
583 try:
584 formName = "{0:d}".format(int(form.name))
585 except ValueError:
586 formName = '"{0}"'.format(form.name)
587 for element in form.elements:
588 name = element[0]
589 value = element[1]
590
591 disabled = page.mainFrame().evaluateJavaScript(
592 'document.forms[{0}].elements["{1}"].disabled'.format(
593 formName, name))
594 if disabled:
595 continue
596
597 readOnly = page.mainFrame().evaluateJavaScript(
598 'document.forms[{0}].elements["{1}"].readOnly'.format(
599 formName, name))
600 if readOnly:
601 continue
602
603 type_ = page.mainFrame().evaluateJavaScript(
604 'document.forms[{0}].elements["{1}"].type'.format(
605 formName, name))
606 if type_ == "" or \
607 type_ in ["hidden", "reset", "submit"]:
608 continue
609 if type_ == "password":
610 value = Utilities.crypto.pwConvert(
611 self.__logins[key][1], encode=False)
612 setType = type_ == "checkbox" and "checked" or "value"
613 value = value.replace("\\", "\\\\")
614 value = value.replace('"', '\\"')
615 javascript = \
616 'document.forms[{0}].elements["{1}"].{2}="{3}";'.format(
617 formName, name, setType, value)
618 page.mainFrame().evaluateJavaScript(javascript)
619
620 def masterPasswordChanged(self, oldPassword, newPassword):
621 """
622 Public slot to handle the change of the master password.
623
624 @param oldPassword current master password (string)
625 @param newPassword new master password (string)
626 """
627 if not self.__loaded:
628 self.__load()
629
630 progress = E5ProgressDialog(
631 self.tr("Re-encoding saved passwords..."),
632 None, 0, len(self.__logins), self.tr("%v/%m Passwords"),
633 QApplication.activeModalWidget())
634 progress.setMinimumDuration(0)
635 progress.setWindowTitle(self.tr("Passwords"))
636 count = 0
637
638 for key in self.__logins:
639 progress.setValue(count)
640 QCoreApplication.processEvents()
641 username, oldPwHash = self.__logins[key]
642 newPwHash = Utilities.crypto.pwRecode(oldPwHash, oldPassword,
643 newPassword)
644 self.__logins[key] = (username, newPwHash)
645 count += 1
646
647 progress.setValue(len(self.__logins))
648 QCoreApplication.processEvents()
649 self.changed.emit()

eric ide

mercurial