|
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() |