1# -*- coding: utf-8 -*-
2
3# Copyright (c) 2009 - 2021 Detlev Offenbach <detlev@die-offenbachs.de>
4#
5
6"""
7Module implementing the password manager.
8"""
9
10import os
11
12from PyQt5.QtCore import (
13    pyqtSignal, QObject, QByteArray, QUrl, QCoreApplication, QXmlStreamReader
14)
15from PyQt5.QtWidgets import QApplication
16from PyQt5.QtWebEngineWidgets import QWebEngineScript
17
18from E5Gui import E5MessageBox
19from E5Gui.E5ProgressDialog import E5ProgressDialog
20
21from Utilities.AutoSaver import AutoSaver
22import Utilities
23import Utilities.crypto
24import Preferences
25
26import WebBrowser.WebBrowserWindow
27from ..Tools import Scripts
28from ..WebBrowserPage import WebBrowserPage
29
30
31class 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    def __init__(self, parent=None):
42        """
43        Constructor
44
45        @param parent reference to the parent object (QObject)
46        """
47        super().__init__(parent)
48
49        # setup userscript to monitor forms
50        script = QWebEngineScript()
51        script.setName("_eric_passwordmonitor")
52        script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady)
53        script.setWorldId(WebBrowserPage.SafeJsWorld)
54        script.setRunsOnSubFrames(True)
55        script.setSourceCode(Scripts.setupFormObserver())
56        profile = WebBrowser.WebBrowserWindow.WebBrowserWindow.webProfile()
57        profile.scripts().insert(script)
58
59        self.__logins = {}
60        self.__loginForms = {}
61        self.__never = []
62        self.__loaded = False
63        self.__saveTimer = AutoSaver(self, self.save)
64
65        self.changed.connect(self.__saveTimer.changeOccurred)
66
67    def clear(self):
68        """
69        Public slot to clear the saved passwords.
70        """
71        if not self.__loaded:
72            self.__load()
73
74        self.__logins = {}
75        self.__loginForms = {}
76        self.__never = []
77        self.__saveTimer.changeOccurred()
78        self.__saveTimer.saveIfNeccessary()
79
80        self.changed.emit()
81
82    def getLogin(self, url, realm):
83        """
84        Public method to get the login credentials.
85
86        @param url URL to get the credentials for (QUrl)
87        @param realm realm to get the credentials for (string)
88        @return tuple containing the user name (string) and password (string)
89        """
90        if not self.__loaded:
91            self.__load()
92
93        key = self.__createKey(url, realm)
94        try:
95            return self.__logins[key][0], Utilities.crypto.pwConvert(
96                self.__logins[key][1], encode=False)
97        except KeyError:
98            return "", ""
99
100    def setLogin(self, url, realm, username, password):
101        """
102        Public method to set the login credentials.
103
104        @param url URL to set the credentials for (QUrl)
105        @param realm realm to set the credentials for (string)
106        @param username username for the login (string)
107        @param password password for the login (string)
108        """
109        if not self.__loaded:
110            self.__load()
111
112        key = self.__createKey(url, realm)
113        self.__logins[key] = (
114            username,
115            Utilities.crypto.pwConvert(password, encode=True)
116        )
117        self.changed.emit()
118
119    def __createKey(self, url, realm):
120        """
121        Private method to create the key string for the login credentials.
122
123        @param url URL to get the credentials for (QUrl)
124        @param realm realm to get the credentials for (string)
125        @return key string (string)
126        """
127        authority = url.authority()
128        if authority.startswith("@"):
129            authority = authority[1:]
130        key = (
131            "{0}://{1} ({2})".format(url.scheme(), authority, realm)
132            if realm else
133            "{0}://{1}".format(url.scheme(), authority)
134        )
135        return key
136
137    def getFileName(self):
138        """
139        Public method to get the file name of the passwords file.
140
141        @return name of the passwords file (string)
142        """
143        return os.path.join(Utilities.getConfigDir(),
144                            "web_browser", "logins.xml")
145
146    def save(self):
147        """
148        Public slot to save the login entries to disk.
149        """
150        if not self.__loaded:
151            return
152
153        from WebBrowser.WebBrowserWindow import WebBrowserWindow
154        if not WebBrowserWindow.isPrivate():
155            from .PasswordWriter import PasswordWriter
156            loginFile = self.getFileName()
157            writer = PasswordWriter()
158            if not writer.write(
159                    loginFile, self.__logins, self.__loginForms, self.__never):
160                E5MessageBox.critical(
161                    None,
162                    self.tr("Saving login data"),
163                    self.tr(
164                        """<p>Login data could not be saved to"""
165                        """ <b>{0}</b></p>"""
166                    ).format(loginFile))
167            else:
168                self.passwordsSaved.emit()
169
170    def __load(self):
171        """
172        Private method to load the saved login credentials.
173        """
174        if self.__loaded:
175            return
176
177        loginFile = self.getFileName()
178        if os.path.exists(loginFile):
179            from .PasswordReader import PasswordReader
180            reader = PasswordReader()
181            self.__logins, self.__loginForms, self.__never = reader.read(
182                loginFile)
183            if reader.error() != QXmlStreamReader.Error.NoError:
184                E5MessageBox.warning(
185                    None,
186                    self.tr("Loading login data"),
187                    self.tr("""Error when loading login data on"""
188                            """ line {0}, column {1}:\n{2}""")
189                    .format(reader.lineNumber(),
190                            reader.columnNumber(),
191                            reader.errorString()))
192
193        self.__loaded = True
194
195    def reload(self):
196        """
197        Public method to reload the login data.
198        """
199        if not self.__loaded:
200            return
201
202        self.__loaded = False
203        self.__load()
204
205    def close(self):
206        """
207        Public method to close the passwords manager.
208        """
209        self.__saveTimer.saveIfNeccessary()
210
211    def removePassword(self, site):
212        """
213        Public method to remove a password entry.
214
215        @param site web site name (string)
216        """
217        if site in self.__logins:
218            del self.__logins[site]
219            if site in self.__loginForms:
220                del self.__loginForms[site]
221            self.changed.emit()
222
223    def allSiteNames(self):
224        """
225        Public method to get a list of all site names.
226
227        @return sorted list of all site names (list of strings)
228        """
229        if not self.__loaded:
230            self.__load()
231
232        return sorted(self.__logins.keys())
233
234    def sitesCount(self):
235        """
236        Public method to get the number of available sites.
237
238        @return number of sites (integer)
239        """
240        if not self.__loaded:
241            self.__load()
242
243        return len(self.__logins)
244
245    def siteInfo(self, site):
246        """
247        Public method to get a reference to the named site.
248
249        @param site web site name (string)
250        @return tuple containing the user name (string) and password (string)
251        """
252        if not self.__loaded:
253            self.__load()
254
255        if site not in self.__logins:
256            return None
257
258        return self.__logins[site][0], Utilities.crypto.pwConvert(
259            self.__logins[site][1], encode=False)
260
261    def formSubmitted(self, urlStr, userName, password, data, page):
262        """
263        Public method to record login data.
264
265        @param urlStr form submission URL
266        @type str
267        @param userName name of the user
268        @type str
269        @param password user password
270        @type str
271        @param data data to be submitted
272        @type QByteArray
273        @param page reference to the calling page
274        @type QWebEnginePage
275        """
276        # shall passwords be saved?
277        if not Preferences.getUser("SavePasswords"):
278            return
279
280        if WebBrowser.WebBrowserWindow.WebBrowserWindow.isPrivate():
281            return
282
283        if not self.__loaded:
284            self.__load()
285
286        if urlStr in self.__never:
287            return
288
289        if userName and password:
290            url = QUrl(urlStr)
291            url = self.__stripUrl(url)
292            key = self.__createKey(url, "")
293            if key not in self.__loginForms:
294                mb = E5MessageBox.E5MessageBox(
295                    E5MessageBox.Question,
296                    self.tr("Save password"),
297                    self.tr(
298                        """<b>Would you like to save this password?</b><br/>"""
299                        """To review passwords you have saved and remove"""
300                        """ them, use the password management dialog of the"""
301                        """ Settings menu."""),
302                    modal=True, parent=page.view())
303                neverButton = mb.addButton(
304                    self.tr("Never for this site"),
305                    E5MessageBox.DestructiveRole)
306                noButton = mb.addButton(
307                    self.tr("Not now"), E5MessageBox.RejectRole)
308                mb.addButton(E5MessageBox.Yes)
309                mb.exec()
310                if mb.clickedButton() == neverButton:
311                    self.__never.append(url.toString())
312                    return
313                elif mb.clickedButton() == noButton:
314                    return
315
316            self.__logins[key] = (
317                userName,
318                Utilities.crypto.pwConvert(password, encode=True)
319            )
320            from .LoginForm import LoginForm
321            form = LoginForm()
322            form.url = url
323            form.name = userName
324            form.postData = Utilities.crypto.pwConvert(
325                bytes(data).decode("utf-8"), encode=True)
326            self.__loginForms[key] = form
327            self.changed.emit()
328
329    def __stripUrl(self, url):
330        """
331        Private method to strip off all unneeded parts of a URL.
332
333        @param url URL to be stripped (QUrl)
334        @return stripped URL (QUrl)
335        """
336        cleanUrl = QUrl(url)
337        cleanUrl.setQuery("")
338        cleanUrl.setUserInfo("")
339
340        authority = cleanUrl.authority()
341        if authority.startswith("@"):
342            authority = authority[1:]
343        cleanUrl = QUrl("{0}://{1}{2}".format(
344            cleanUrl.scheme(), authority, cleanUrl.path()))
345        cleanUrl.setFragment("")
346        return cleanUrl
347
348    def completePage(self, page):
349        """
350        Public slot to complete login forms with saved data.
351
352        @param page reference to the web page (WebBrowserPage)
353        """
354        if page is None:
355            return
356
357        if not self.__loaded:
358            self.__load()
359
360        url = page.url()
361        url = self.__stripUrl(url)
362        key = self.__createKey(url, "")
363        if (
364            key not in self.__loginForms or
365            key not in self.__logins
366        ):
367            return
368
369        form = self.__loginForms[key]
370        if form.url != url:
371            return
372
373        postData = QByteArray(Utilities.crypto.pwConvert(
374            form.postData, encode=False).encode("utf-8"))
375        script = Scripts.completeFormData(postData)
376        page.runJavaScript(script, WebBrowserPage.SafeJsWorld)
377
378    def masterPasswordChanged(self, oldPassword, newPassword):
379        """
380        Public slot to handle the change of the master password.
381
382        @param oldPassword current master password (string)
383        @param newPassword new master password (string)
384        """
385        if not self.__loaded:
386            self.__load()
387
388        progress = E5ProgressDialog(
389            self.tr("Re-encoding saved passwords..."),
390            None, 0, len(self.__logins) + len(self.__loginForms),
391            self.tr("%v/%m Passwords"),
392            QApplication.activeModalWidget())
393        progress.setMinimumDuration(0)
394        progress.setWindowTitle(self.tr("Passwords"))
395        count = 0
396
397        # step 1: do the logins
398        for key in self.__logins:
399            progress.setValue(count)
400            QCoreApplication.processEvents()
401            username, pwHash = self.__logins[key]
402            pwHash = Utilities.crypto.pwRecode(
403                pwHash, oldPassword, newPassword)
404            self.__logins[key] = (username, pwHash)
405            count += 1
406
407        # step 2: do the login forms
408        for key in self.__loginForms:
409            progress.setValue(count)
410            QCoreApplication.processEvents()
411            postData = self.__loginForms[key].postData
412            postData = Utilities.crypto.pwRecode(
413                postData, oldPassword, newPassword)
414            self.__loginForms[key].postData = postData
415            count += 1
416
417        progress.setValue(len(self.__logins) + len(self.__loginForms))
418        QCoreApplication.processEvents()
419        self.changed.emit()
420
421#
422# eflag: noqa = Y113
423