Wed, 01 Jan 2014 14:40:41 +0100
Updated copyright for 2014.
# -*- coding: utf-8 -*- # Copyright (c) 2009 - 2014 Detlev Offenbach <detlev@die-offenbachs.de> # """ Module implementing the password manager. """ import os from PyQt4.QtCore import pyqtSignal, QObject, QByteArray, QUrl, QCoreApplication, \ QXmlStreamReader from PyQt4.QtGui import QProgressDialog, QApplication from PyQt4.QtNetwork import QNetworkRequest from PyQt4.QtWebKit import QWebSettings, QWebPage from E5Gui import E5MessageBox from Helpviewer.JavaScriptResources import parseForms_js from .LoginForm import LoginForm from .PasswordWriter import PasswordWriter from .PasswordReader import PasswordReader 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() SEPARATOR = "====================" FORMS = "=====FORMS=====" NEVER = "=====NEVER=====" def __init__(self, parent=None): """ Constructor @param parent reference to the parent object (QObject) """ super().__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) """ if realm: key = "{0}://{1} ({2})".format(url.scheme(), url.authority(), realm) else: key = "{0}://{1}".format(url.scheme(), url.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(), "browser", "logins.xml") def save(self): """ Public slot to save the login entries to disk. """ if not self.__loaded: return loginFile = self.getFileName() writer = PasswordWriter() if not writer.write(loginFile, self.__logins, self.__loginForms, self.__never): E5MessageBox.critical(None, self.trUtf8("Saving login data"), self.trUtf8("""<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. """ loginFile = self.getFileName() if not os.path.exists(loginFile): self.__loadNonXml(os.path.splitext(loginFile)[0]) else: reader = PasswordReader() self.__logins, self.__loginForms, self.__never = reader.read(loginFile) if reader.error() != QXmlStreamReader.NoError: E5MessageBox.warning(None, self.trUtf8("Loading login data"), self.trUtf8("""Error when loading login data on""" """ line {0}, column {1}:\n{2}""")\ .format(reader.lineNumber(), reader.columnNumber(), reader.errorString())) self.__loaded = True def __loadNonXml(self, loginFile): """ Private method to load non-XML password files. This method is to convert from the old, non-XML format to the new XML based format. @param loginFile name of the non-XML password file (string) """ if os.path.exists(loginFile): try: f = open(loginFile, "r", encoding="utf-8") lines = f.read() f.close() except IOError as err: E5MessageBox.critical(None, self.trUtf8("Loading login data"), self.trUtf8("""<p>Login data could not be loaded """ """from <b>{0}</b></p>""" """<p>Reason: {1}</p>""")\ .format(loginFile, str(err))) return data = [] section = 0 # 0 = login data, 1 = forms data, 2 = never store info for line in lines.splitlines(): if line == self.FORMS: section = 1 continue elif line == self.NEVER: section = 2 continue if section == 0: if line != self.SEPARATOR: data.append(line) else: if len(data) != 3: E5MessageBox.critical(None, self.trUtf8("Loading login data"), self.trUtf8("""<p>Login data could not be loaded """ """from <b>{0}</b></p>""" """<p>Reason: Wrong input format</p>""")\ .format(loginFile)) return self.__logins[data[0]] = (data[1], data[2]) data = [] elif section == 1: if line != self.SEPARATOR: data.append(line) else: key = data[0] form = LoginForm() form.url = QUrl(data[1]) form.name = data[2] form.hasAPassword = data[3] == "True" for element in data[4:]: name, value = element.split(" = ", 1) form.elements.append((name, value)) self.__loginForms[key] = form data = [] elif section == 2: self.__never.append(line) os.remove(loginFile) self.__loaded = True # this does the conversion self.save() def reload(self): """ Public method to reload the login data. """ if not self.__loaded: return 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) 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 if QWebSettings.globalSettings().testAttribute( QWebSettings.PrivateBrowsingEnabled): return if not self.__loaded: self.__load() # determine the url refererHeader = request.rawHeader("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("Content-Type") if contentTypeHeader.isEmpty(): return multipart = contentTypeHeader.startsWith("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.trUtf8("Save password"), self.trUtf8( """<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.trUtf8("Never for this site"), E5MessageBox.DestructiveRole) noButton = mb.addButton(self.trUtf8("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.setQueryItems([]) cleanUrl.setFragment("") cleanUrl.setUserInfo("") return cleanUrl 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) """ form = LoginForm() if boundary is not None: args = self.__extractMultipartQueryItems(data, boundary) else: argsUrl = QUrl.fromEncoded( QByteArray("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 lst = webPage.mainFrame().evaluateJavaScript(parseForms_js) for map in lst: formHasPasswords = False formName = map["name"] formIndex = map["index"] if type(formIndex) == type(0.0) 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 = QProgressDialog(self.trUtf8("Re-encoding saved passwords..."), None, 0, len(self.__logins), QApplication.activeModalWidget()) progress.setMinimumDuration(0) 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()