|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2009 - 2022 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing the password manager. |
|
8 """ |
|
9 |
|
10 import os |
|
11 |
|
12 from PyQt6.QtCore import ( |
|
13 pyqtSignal, QObject, QByteArray, QUrl, QCoreApplication, QXmlStreamReader |
|
14 ) |
|
15 from PyQt6.QtWidgets import QApplication |
|
16 from PyQt6.QtWebEngineCore import QWebEngineScript |
|
17 |
|
18 from EricWidgets import EricMessageBox |
|
19 from EricWidgets.EricProgressDialog import EricProgressDialog |
|
20 |
|
21 from Utilities.AutoSaver import AutoSaver |
|
22 import Utilities |
|
23 import Utilities.crypto |
|
24 import Preferences |
|
25 |
|
26 import WebBrowser.WebBrowserWindow |
|
27 from ..Tools import Scripts |
|
28 from ..WebBrowserPage import WebBrowserPage |
|
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 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 EricMessageBox.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 EricMessageBox.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 = EricMessageBox.EricMessageBox( |
|
295 EricMessageBox.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 EricMessageBox.DestructiveRole) |
|
306 noButton = mb.addButton( |
|
307 self.tr("Not now"), EricMessageBox.RejectRole) |
|
308 mb.addButton(EricMessageBox.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 = EricProgressDialog( |
|
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 |