diff -r f9d1090f6ab9 -r f9e2e536d130 WebBrowser/Passwords/PasswordManager.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/WebBrowser/Passwords/PasswordManager.py Mon Feb 15 20:01:02 2016 +0100 @@ -0,0 +1,568 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2009 - 2016 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing the password manager. +""" + +from __future__ import unicode_literals + +import os + +from PyQt5.QtCore import pyqtSignal, QObject, QByteArray, QUrl, \ + QCoreApplication, QXmlStreamReader, qVersion +from PyQt5.QtWidgets import QApplication +from PyQt5.QtNetwork import QNetworkRequest +##from PyQt5.QtWebKit import QWebSettings +##from PyQt5.QtWebKitWidgets import QWebPage + +from E5Gui import E5MessageBox +from E5Gui.E5ProgressDialog import E5ProgressDialog + +from Utilities.AutoSaver import AutoSaver +import Utilities +import Utilities.crypto +import Preferences + + +class PasswordManager(QObject): + """ + Class implementing the password manager. + + @signal changed() emitted to indicate a change + @signal passwordsSaved() emitted after the passwords were saved + """ + changed = pyqtSignal() + passwordsSaved = pyqtSignal() + + def __init__(self, parent=None): + """ + Constructor + + @param parent reference to the parent object (QObject) + """ + super(PasswordManager, self).__init__(parent) + + self.__logins = {} + self.__loginForms = {} + self.__never = [] + self.__loaded = False + self.__saveTimer = AutoSaver(self, self.save) + + self.changed.connect(self.__saveTimer.changeOccurred) + + def clear(self): + """ + Public slot to clear the saved passwords. + """ + if not self.__loaded: + self.__load() + + self.__logins = {} + self.__loginForms = {} + self.__never = [] + self.__saveTimer.changeOccurred() + self.__saveTimer.saveIfNeccessary() + + self.changed.emit() + + def getLogin(self, url, realm): + """ + Public method to get the login credentials. + + @param url URL to get the credentials for (QUrl) + @param realm realm to get the credentials for (string) + @return tuple containing the user name (string) and password (string) + """ + if not self.__loaded: + self.__load() + + key = self.__createKey(url, realm) + try: + return self.__logins[key][0], Utilities.crypto.pwConvert( + self.__logins[key][1], encode=False) + except KeyError: + return "", "" + + def setLogin(self, url, realm, username, password): + """ + Public method to set the login credentials. + + @param url URL to set the credentials for (QUrl) + @param realm realm to set the credentials for (string) + @param username username for the login (string) + @param password password for the login (string) + """ + if not self.__loaded: + self.__load() + + key = self.__createKey(url, realm) + self.__logins[key] = ( + username, + Utilities.crypto.pwConvert(password, encode=True) + ) + self.changed.emit() + + def __createKey(self, url, realm): + """ + Private method to create the key string for the login credentials. + + @param url URL to get the credentials for (QUrl) + @param realm realm to get the credentials for (string) + @return key string (string) + """ + authority = url.authority() + if authority.startswith("@"): + authority = authority[1:] + if realm: + key = "{0}://{1} ({2})".format( + url.scheme(), authority, realm) + else: + key = "{0}://{1}".format(url.scheme(), authority) + return key + + def getFileName(self): + """ + Public method to get the file name of the passwords file. + + @return name of the passwords file (string) + """ + return os.path.join(Utilities.getConfigDir(), + "web_browser", "logins.xml") + + def save(self): + """ + Public slot to save the login entries to disk. + """ + if not self.__loaded: + return + + from .PasswordWriter import PasswordWriter + loginFile = self.getFileName() + writer = PasswordWriter() + if not writer.write( + loginFile, self.__logins, self.__loginForms, self.__never): + E5MessageBox.critical( + None, + self.tr("Saving login data"), + self.tr( + """<p>Login data could not be saved to <b>{0}</b></p>""" + ).format(loginFile)) + else: + self.passwordsSaved.emit() + + def __load(self): + """ + Private method to load the saved login credentials. + """ + if self.__loaded: + return + + loginFile = self.getFileName() + if os.path.exists(loginFile): + from .PasswordReader import PasswordReader + reader = PasswordReader() + self.__logins, self.__loginForms, self.__never = \ + reader.read(loginFile) + if reader.error() != QXmlStreamReader.NoError: + E5MessageBox.warning( + None, + self.tr("Loading login data"), + self.tr("""Error when loading login data on""" + """ line {0}, column {1}:\n{2}""") + .format(reader.lineNumber(), + reader.columnNumber(), + reader.errorString())) + + self.__loaded = True + + def reload(self): + """ + Public method to reload the login data. + """ + if not self.__loaded: + return + + self.__loaded = False + self.__load() + + def close(self): + """ + Public method to close the passwords manager. + """ + self.__saveTimer.saveIfNeccessary() + + def removePassword(self, site): + """ + Public method to remove a password entry. + + @param site web site name (string) + """ + if site in self.__logins: + del self.__logins[site] + if site in self.__loginForms: + del self.__loginForms[site] + self.changed.emit() + + def allSiteNames(self): + """ + Public method to get a list of all site names. + + @return sorted list of all site names (list of strings) + """ + if not self.__loaded: + self.__load() + + return sorted(self.__logins.keys()) + + def sitesCount(self): + """ + Public method to get the number of available sites. + + @return number of sites (integer) + """ + if not self.__loaded: + self.__load() + + return len(self.__logins) + + def siteInfo(self, site): + """ + Public method to get a reference to the named site. + + @param site web site name (string) + @return tuple containing the user name (string) and password (string) + """ + if not self.__loaded: + self.__load() + + if site not in self.__logins: + return None + + return self.__logins[site][0], Utilities.crypto.pwConvert( + self.__logins[site][1], encode=False) + + # TODO: Password Manager: processing of form data + def post(self, request, data): + """ + Public method to check, if the data to be sent contains login data. + + @param request reference to the network request (QNetworkRequest) + @param data data to be sent (QByteArray) + """ +## # shall passwords be saved? +## if not Preferences.getUser("SavePasswords"): +## return +## +## # observe privacy +## # TODO: Privacy, i.e. isPrivate() +#### if QWebSettings.globalSettings().testAttribute( +#### QWebSettings.PrivateBrowsingEnabled): +#### return +## +## if not self.__loaded: +## self.__load() +## +## # determine the url +## refererHeader = request.rawHeader(b"Referer") +## if refererHeader.isEmpty(): +## return +## url = QUrl.fromEncoded(refererHeader) +## url = self.__stripUrl(url) +## +## # check that url isn't in __never +## if url.toString() in self.__never: +## return +## +## # check the request type +## navType = request.attribute(QNetworkRequest.User + 101) +## if navType is None: +## return +## if navType != QWebPage.NavigationTypeFormSubmitted: +## return +## +## # determine the QWebPage +## webPage = request.attribute(QNetworkRequest.User + 100) +## if webPage is None: +## return +## +## # determine the requests content type +## contentTypeHeader = request.rawHeader(b"Content-Type") +## if contentTypeHeader.isEmpty(): +## return +## multipart = contentTypeHeader.startsWith(b"multipart/form-data") +## if multipart: +## boundary = contentTypeHeader.split(" ")[1].split("=")[1] +## else: +## boundary = None +## +## # find the matching form on the web page +## form = self.__findForm(webPage, data, boundary=boundary) +## if not form.isValid(): +## return +## form.url = QUrl(url) +## +## # check, if the form has a password +## if not form.hasAPassword: +## return +## +## # prompt, if the form has never be seen +## key = self.__createKey(url, "") +## if key not in self.__loginForms: +## mb = E5MessageBox.E5MessageBox( +## E5MessageBox.Question, +## self.tr("Save password"), +## self.tr( +## """<b>Would you like to save this password?</b><br/>""" +## """To review passwords you have saved and remove them, """ +## """use the password management dialog of the Settings""" +## """ menu."""), +## modal=True) +## neverButton = mb.addButton( +## self.tr("Never for this site"), +## E5MessageBox.DestructiveRole) +## noButton = mb.addButton( +## self.tr("Not now"), E5MessageBox.RejectRole) +## mb.addButton(E5MessageBox.Yes) +## mb.exec_() +## if mb.clickedButton() == neverButton: +## self.__never.append(url.toString()) +## return +## elif mb.clickedButton() == noButton: +## return +## +## # extract user name and password +## user = "" +## password = "" +## for index in range(len(form.elements)): +## element = form.elements[index] +## type_ = form.elementTypes[element[0]] +## if user == "" and \ +## type_ == "text": +## user = element[1] +## elif password == "" and \ +## type_ == "password": +## password = element[1] +## form.elements[index] = (element[0], "--PASSWORD--") +## if user and password: +## self.__logins[key] = \ +## (user, Utilities.crypto.pwConvert(password, encode=True)) +## self.__loginForms[key] = form +## self.changed.emit() + + def __stripUrl(self, url): + """ + Private method to strip off all unneeded parts of a URL. + + @param url URL to be stripped (QUrl) + @return stripped URL (QUrl) + """ + cleanUrl = QUrl(url) + cleanUrl.setQuery("") + cleanUrl.setUserInfo("") + + authority = cleanUrl.authority() + if authority.startswith("@"): + authority = authority[1:] + cleanUrl = QUrl("{0}://{1}{2}".format( + cleanUrl.scheme(), authority, cleanUrl.path())) + cleanUrl.setFragment("") + return cleanUrl + + # TODO: Password Manager: processing of form data +## def __findForm(self, webPage, data, boundary=None): +## """ +## Private method to find the form used for logging in. +## +## @param webPage reference to the web page (QWebPage) +## @param data data to be sent (QByteArray) +## @keyparam boundary boundary string (QByteArray) for multipart +## encoded data, None for urlencoded data +## @return parsed form (LoginForm) +## """ +## from .LoginForm import LoginForm +## form = LoginForm() +## if boundary is not None: +## args = self.__extractMultipartQueryItems(data, boundary) +## else: +## if qVersion() >= "5.0.0": +## from PyQt5.QtCore import QUrlQuery +## argsUrl = QUrl.fromEncoded( +## QByteArray(b"foo://bar.com/?" + QUrl.fromPercentEncoding( +## data.replace(b"+", b"%20")).encode("utf-8"))) +## encodedArgs = QUrlQuery(argsUrl).queryItems() +## else: +## argsUrl = QUrl.fromEncoded( +## QByteArray(b"foo://bar.com/?" + data.replace(b"+", b"%20")) +## ) +## encodedArgs = argsUrl.queryItems() +## args = set() +## for arg in encodedArgs: +## key = arg[0] +## value = arg[1] +## args.add((key, value)) +## +## # extract the forms +## from Helpviewer.JavaScriptResources import parseForms_js +## lst = webPage.mainFrame().evaluateJavaScript(parseForms_js) +## for map in lst: +## formHasPasswords = False +## formName = map["name"] +## formIndex = map["index"] +## if isinstance(formIndex, float) and formIndex.is_integer(): +## formIndex = int(formIndex) +## elements = map["elements"] +## formElements = set() +## formElementTypes = {} +## deadElements = set() +## for elementMap in elements: +## try: +## name = elementMap["name"] +## value = elementMap["value"] +## type_ = elementMap["type"] +## except KeyError: +## continue +## if type_ == "password": +## formHasPasswords = True +## t = (name, value) +## try: +## if elementMap["autocomplete"] == "off": +## deadElements.add(t) +## except KeyError: +## pass +## if name: +## formElements.add(t) +## formElementTypes[name] = type_ +## if formElements.intersection(args) == args: +## form.hasAPassword = formHasPasswords +## if not formName: +## form.name = formIndex +## else: +## form.name = formName +## args.difference_update(deadElements) +## for elt in deadElements: +## if elt[0] in formElementTypes: +## del formElementTypes[elt[0]] +## form.elements = list(args) +## form.elementTypes = formElementTypes +## break +## +## return form +## +## def __extractMultipartQueryItems(self, data, boundary): +## """ +## Private method to extract the query items for a post operation. +## +## @param data data to be sent (QByteArray) +## @param boundary boundary string (QByteArray) +## @return set of name, value pairs (set of tuple of string, string) +## """ +## args = set() +## +## dataStr = bytes(data).decode() +## boundaryStr = bytes(boundary).decode() +## +## parts = dataStr.split(boundaryStr + "\r\n") +## for part in parts: +## if part.startswith("Content-Disposition"): +## lines = part.split("\r\n") +## name = lines[0].split("=")[1][1:-1] +## value = lines[2] +## args.add((name, value)) +## +## return args +## +## def fill(self, page): +## """ +## Public slot to fill login forms with saved data. +## +## @param page reference to the web page (QWebPage) +## """ +## if page is None or page.mainFrame() is None: +## return +## +## if not self.__loaded: +## self.__load() +## +## url = page.mainFrame().url() +## url = self.__stripUrl(url) +## key = self.__createKey(url, "") +## if key not in self.__loginForms or \ +## key not in self.__logins: +## return +## +## form = self.__loginForms[key] +## if form.url != url: +## return +## +## if form.name == "": +## formName = "0" +## else: +## try: +## formName = "{0:d}".format(int(form.name)) +## except ValueError: +## formName = '"{0}"'.format(form.name) +## for element in form.elements: +## name = element[0] +## value = element[1] +## +## disabled = page.mainFrame().evaluateJavaScript( +## 'document.forms[{0}].elements["{1}"].disabled'.format( +## formName, name)) +## if disabled: +## continue +## +## readOnly = page.mainFrame().evaluateJavaScript( +## 'document.forms[{0}].elements["{1}"].readOnly'.format( +## formName, name)) +## if readOnly: +## continue +## +## type_ = page.mainFrame().evaluateJavaScript( +## 'document.forms[{0}].elements["{1}"].type'.format( +## formName, name)) +## if type_ == "" or \ +## type_ in ["hidden", "reset", "submit"]: +## continue +## if type_ == "password": +## value = Utilities.crypto.pwConvert( +## self.__logins[key][1], encode=False) +## setType = type_ == "checkbox" and "checked" or "value" +## value = value.replace("\\", "\\\\") +## value = value.replace('"', '\\"') +## javascript = \ +## 'document.forms[{0}].elements["{1}"].{2}="{3}";'.format( +## formName, name, setType, value) +## page.mainFrame().evaluateJavaScript(javascript) + + def masterPasswordChanged(self, oldPassword, newPassword): + """ + Public slot to handle the change of the master password. + + @param oldPassword current master password (string) + @param newPassword new master password (string) + """ + if not self.__loaded: + self.__load() + + progress = E5ProgressDialog( + self.tr("Re-encoding saved passwords..."), + None, 0, len(self.__logins), self.tr("%v/%m Passwords"), + QApplication.activeModalWidget()) + progress.setMinimumDuration(0) + progress.setWindowTitle(self.tr("Passwords")) + count = 0 + + for key in self.__logins: + progress.setValue(count) + QCoreApplication.processEvents() + username, hash = self.__logins[key] + hash = Utilities.crypto.pwRecode(hash, oldPassword, newPassword) + self.__logins[key] = (username, hash) + count += 1 + + progress.setValue(len(self.__logins)) + QCoreApplication.processEvents() + self.changed.emit()