1#!/usr/local/bin/python3.8
2
3try:
4    import pam
5    print("Using pam module (python3-pampy)")
6except:
7    import PAM
8    print("Using PAM module (python3-pam)")
9    pam = None
10import pexpect
11import time
12from random import randint
13import shutil
14import os
15import subprocess
16
17import PIL
18import gi
19gi.require_version('AccountsService', '1.0')
20from gi.repository import AccountsService, GLib, GdkPixbuf
21
22from SettingsWidgets import SidePage
23from ChooserButtonWidgets import PictureChooserButton
24from xapp.GSettingsWidgets import *
25
26class PasswordError(Exception):
27    '''Exception raised when an incorrect password is supplied.'''
28    pass
29
30
31class Module:
32    name = "user"
33    category = "prefs"
34    comment = _("Change your user preferences and password")
35
36    def __init__(self, content_box):
37        keywords = _("user, account, information, details, password")
38        sidePage = SidePage(_("Account details"), "cs-user", keywords, content_box, module=self)
39        self.sidePage = sidePage
40        self.window = None
41
42    def _setParentRef(self, window):
43        self.window = window
44
45    def on_module_selected(self):
46        if not self.loaded:
47            print("Loading User module")
48
49            page = SettingsPage()
50            self.sidePage.add_widget(page)
51
52            settings = page.add_section(_("Account details"))
53
54            self.scale = self.window.get_scale_factor()
55
56            self.face_button = PictureChooserButton(num_cols=4, button_picture_size=64, menu_pictures_size=64*self.scale, keep_square=True)
57            self.face_button.set_alignment(0.0, 0.5)
58            self.face_button.set_tooltip_text(_("Click to change your picture"))
59
60            self.face_photo_menuitem = Gtk.MenuItem.new_with_label(_("Take a photo..."))
61            self.face_photo_menuitem.connect('activate', self._on_face_photo_menuitem_activated)
62
63            self.face_browse_menuitem = Gtk.MenuItem.new_with_label(_("Browse for more pictures..."))
64            self.face_browse_menuitem.connect('activate', self._on_face_browse_menuitem_activated)
65
66            face_dirs = ["/usr/local/share/cinnamon/faces"]
67            for face_dir in face_dirs:
68                if os.path.exists(face_dir):
69                    pictures = sorted(os.listdir(face_dir))
70                    for picture in pictures:
71                        path = os.path.join(face_dir, picture)
72                        self.face_button.add_picture(path, self._on_face_menuitem_activated)
73
74            widget = SettingsWidget()
75            label = Gtk.Label.new(_("Picture"))
76            widget.pack_start(label, False, False, 0)
77            widget.pack_end(self.face_button, False, False, 0)
78            settings.add_row(widget)
79
80            size_group = Gtk.SizeGroup.new(Gtk.SizeGroupMode.HORIZONTAL)
81
82            widget = SettingsWidget()
83            label = Gtk.Label.new(_("Name"))
84            widget.pack_start(label, False, False, 0)
85            self.realname_entry = EditableEntry()
86            size_group.add_widget(self.realname_entry)
87            self.realname_entry.connect("changed", self._on_realname_changed)
88            self.realname_entry.set_tooltip_text(_("Click to change your name"))
89            widget.pack_end(self.realname_entry, False, False, 0)
90            settings.add_row(widget)
91
92            widget = SettingsWidget()
93            label = Gtk.Label.new(_("Password"))
94            widget.pack_start(label, False, False, 0)
95            password_mask = Gtk.Label.new('\u2022\u2022\u2022\u2022\u2022\u2022')
96            password_mask.set_alignment(0.9, 0.5)
97            self.password_button = Gtk.Button()
98            size_group.add_widget(self.password_button)
99            self.password_button.add(password_mask)
100            self.password_button.set_relief(Gtk.ReliefStyle.NONE)
101            self.password_button.set_tooltip_text(_("Click to change your password"))
102            self.password_button.connect('activate', self._on_password_button_clicked)
103            self.password_button.connect('released', self._on_password_button_clicked)
104            widget.pack_end(self.password_button, False, False, 0)
105            settings.add_row(widget)
106
107            current_user = GLib.get_user_name()
108            self.accountService = AccountsService.UserManager.get_default().get_user(current_user)
109            self.accountService.connect('notify::is-loaded', self.load_user_info)
110
111            self.face_button.add_separator()
112
113            # Video devices assumed to be webcams
114            import glob
115            webcam_detected = len(glob.glob("/dev/video*")) > 0
116
117            if webcam_detected:
118                self.face_button.add_menuitem(self.face_photo_menuitem)
119
120            self.face_button.add_menuitem(self.face_browse_menuitem)
121
122    def update_preview_cb (self, dialog, preview):
123        filename = dialog.get_preview_filename()
124        if filename is not None:
125            if os.path.isfile(filename):
126                try:
127                    pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(filename, 128, 128)
128                    if pixbuf is not None:
129                        preview.set_from_pixbuf(pixbuf)
130                        self.frame.show()
131                        return
132                except GLib.Error as e:
133                    print("Unable to generate preview for file '%s' - %s\n" % (filename, e.message))
134
135        preview.clear()
136        self.frame.hide()
137
138    def _on_face_photo_menuitem_activated(self, menuitem):
139
140        # streamer takes -t photos, uses /dev/video0
141        if 0 != subprocess.call(["streamer", "-j90", "-t8", "-s800x600", "-o", "/tmp/temp-account-pic00.jpeg"]):
142            print("Error: Webcam not available")
143            return
144
145        # Use the 8th frame (the webcam takes a few frames to "lighten up")
146        path = "/tmp/temp-account-pic07.jpeg"
147
148        # Crop the image to thumbnail size
149        image = PIL.Image.open(path)
150        width, height = image.size
151
152        if width > height:
153            new_width = height
154            new_height = height
155        elif height > width:
156            new_width = width
157            new_height = width
158        else:
159            new_width = width
160            new_height = height
161
162        left = (width - new_width) / 2
163        top = (height - new_height) / 2
164        right = (width + new_width) / 2
165        bottom = (height + new_height) / 2
166
167        image = image.crop((left, top, right, bottom))
168        image.thumbnail((255, 255), PIL.Image.ANTIALIAS)
169
170        face_path = os.path.join(self.accountService.get_home_dir(), ".face")
171
172        image.save(face_path, "png")
173        self.accountService.set_icon_file(face_path)
174        self.face_button.set_picture_from_file(face_path)
175
176
177    def _on_face_browse_menuitem_activated(self, menuitem):
178        dialog = Gtk.FileChooserDialog(None, None, Gtk.FileChooserAction.OPEN, (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK))
179        dialog.set_current_folder(self.accountService.get_home_dir())
180        filter = Gtk.FileFilter()
181        filter.set_name(_("Images"))
182        filter.add_mime_type("image/*")
183        dialog.add_filter(filter)
184
185        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
186        self.frame = Gtk.Frame(visible=False, no_show_all=True)
187        preview = Gtk.Image(visible=True)
188
189        box.pack_start(self.frame, False, False, 0)
190        self.frame.add(preview)
191        dialog.set_preview_widget(box)
192        dialog.set_preview_widget_active(True)
193        dialog.set_use_preview_label(False)
194
195        box.set_margin_end(12)
196        box.set_margin_top(12)
197        box.set_size_request(128, -1)
198
199        dialog.connect("update-preview", self.update_preview_cb, preview)
200
201        response = dialog.run()
202        if response == Gtk.ResponseType.OK:
203            path = dialog.get_filename()
204            image = PIL.Image.open(path)
205            image.thumbnail((255, 255), PIL.Image.ANTIALIAS)
206            face_path = os.path.join(self.accountService.get_home_dir(), ".face")
207            image.save(face_path, "png")
208            self.accountService.set_icon_file(face_path)
209            self.face_button.set_picture_from_file(face_path)
210
211        dialog.destroy()
212
213    def _on_face_menuitem_activated(self, path):
214        if os.path.exists(path):
215            self.accountService.set_icon_file(path)
216            shutil.copy(path, os.path.join(self.accountService.get_home_dir(), ".face"))
217            return True
218
219    def load_user_info(self, user, param):
220        self.realname_entry.set_text(user.get_real_name())
221        for path in [os.path.join(self.accountService.get_home_dir(), ".face"), user.get_icon_file(), "/usr/local/share/cinnamon/faces/user-generic.png"]:
222            if os.path.exists(path):
223                self.face_button.set_picture_from_file(path)
224                break
225
226    def _on_realname_changed(self, widget, text):
227        self.accountService.set_real_name(text)
228
229    def _on_password_button_clicked(self, widget):
230        dialog = PasswordDialog()
231        response = dialog.run()
232
233
234class PasswordDialog(Gtk.Dialog):
235
236    def __init__ (self):
237        super(PasswordDialog, self).__init__()
238
239        self.correct_current_password = False # Flag to remember if the current password is correct or not
240
241        self.set_modal(True)
242        self.set_skip_taskbar_hint(True)
243        self.set_skip_pager_hint(True)
244        self.set_title(_("Change Password"))
245
246        table = Gtk.Table(6, 3)
247        table.set_border_width(6)
248        table.set_row_spacings(8)
249        table.set_col_spacings(15)
250
251        label = Gtk.Label.new(_("Current password"))
252        label.set_alignment(1, 0.5)
253        table.attach(label, 0, 1, 0, 1)
254
255        label = Gtk.Label.new(_("New password"))
256        label.set_alignment(1, 0.5)
257        table.attach(label, 0, 1, 1, 2)
258
259        label = Gtk.Label.new(_("Confirm password"))
260        label.set_alignment(1, 0.5)
261        table.attach(label, 0, 1, 3, 4)
262
263        self.current_password = Gtk.Entry()
264        self.current_password.set_visibility(False)
265        self.current_password.connect("focus-out-event", self._on_current_password_changed)
266        table.attach(self.current_password, 1, 3, 0, 1)
267
268        self.new_password = Gtk.Entry()
269        self.new_password.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, "view-refresh")
270        self.new_password.set_icon_tooltip_text(Gtk.EntryIconPosition.SECONDARY, _("Generate a password"))
271        self.new_password.set_tooltip_text(_("Generate a password"))
272        self.new_password.connect("icon-release", self._on_new_password_icon_released)
273        self.new_password.connect("changed", self._on_passwords_changed)
274        table.attach(self.new_password, 1, 3, 1, 2)
275
276        self.strengh_indicator = Gtk.ProgressBar()
277        self.strengh_indicator.set_tooltip_text(_("Your new password needs to be at least 8 characters long"))
278        self.strengh_indicator.set_fraction(0.0)
279        table.attach(self.strengh_indicator, 1, 2, 2, 3, xoptions=Gtk.AttachOptions.EXPAND|Gtk.AttachOptions.FILL)
280        self.strengh_indicator.set_size_request(-1, 1)
281
282        self.strengh_label = Gtk.Label()
283        self.strengh_label.set_tooltip_text(_("Your new password needs to be at least 8 characters long"))
284        self.strengh_label.set_alignment(1, 0.5)
285        table.attach(self.strengh_label, 2, 3, 2, 3)
286
287        self.confirm_password = Gtk.Entry()
288        self.confirm_password.connect("changed", self._on_passwords_changed)
289        table.attach(self.confirm_password, 1, 3, 3, 4)
290
291        self.show_password = Gtk.CheckButton(_("Show password"))
292        self.show_password.connect('toggled', self._on_show_password_toggled)
293        table.attach(self.show_password, 1, 3, 4, 5)
294
295        self.set_border_width(6)
296
297        box = self.get_content_area()
298        box.add(table)
299        self.show_all()
300
301        self.infobar = Gtk.InfoBar()
302        self.infobar.set_message_type(Gtk.MessageType.ERROR)
303        label = Gtk.Label.new(_("An error occurred. Your password was not changed."))
304        content = self.infobar.get_content_area()
305        content.add(label)
306        table.attach(self.infobar, 0, 3, 5, 6)
307
308        self.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, _("Change"), Gtk.ResponseType.OK, )
309
310        self.set_passwords_visibility()
311        self.set_response_sensitive(Gtk.ResponseType.OK, False)
312        self.infobar.hide()
313
314        self.connect("response", self._on_response)
315
316    def _on_response(self, dialog, response_id):
317        if response_id == Gtk.ResponseType.OK:
318            self.change_password()
319        else:
320            self.destroy()
321
322    def change_password(self):
323        oldpass = self.current_password.get_text()
324        newpass = self.new_password.get_text()
325        passwd = pexpect.spawn("/usr/local/bin/passwd")
326        time.sleep(0.5)
327        passwd.sendline(oldpass)
328        time.sleep(0.5)
329        passwd.sendline(newpass)
330        time.sleep(0.5)
331        passwd.sendline(newpass)
332        time.sleep(0.5)
333        passwd.close()
334
335        if passwd.exitstatus is None or passwd.exitstatus > 0:
336            self.infobar.show_all()
337        else:
338            self.destroy()
339
340    def set_passwords_visibility(self):
341        visible = self.show_password.get_active()
342        self.new_password.set_visibility(visible)
343        self.confirm_password.set_visibility(visible)
344
345    def _on_new_password_icon_released(self, widget, icon_pos, event):
346        self.infobar.hide()
347        self.show_password.set_active(True)
348        characters = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-"
349        newpass = ""
350        for i in range (8):
351            index = randint(0, len(characters) -1)
352            newpass = newpass + characters[index]
353
354        self.new_password.set_text(newpass)
355        self.confirm_password.set_text(newpass)
356        self.check_passwords()
357
358    def _on_show_password_toggled(self, widget):
359        self.set_passwords_visibility()
360
361    def auth_pam(self):
362        if not pam.pam().authenticate(GLib.get_user_name(), self.current_password.get_text(), 'passwd'):
363            raise PasswordError("Invalid password")
364
365    def auth_PyPAM(self):
366        auth = PAM.pam()
367        auth.start('passwd')
368        auth.set_item(PAM.PAM_USER, GLib.get_user_name())
369        auth.set_item(PAM.PAM_CONV, self.pam_conv)
370        try:
371            auth.authenticate()
372            auth.acct_mgmt()
373            return True
374        except PAM.error as resp:
375            raise PasswordError("Invalid password")
376
377    def _on_current_password_changed(self, widget, event):
378        self.infobar.hide()
379        if self.current_password.get_text() != "":
380            try:
381                self.auth_pam() if pam else self.auth_PyPAM()
382            except PasswordError:
383                self.current_password.set_icon_from_stock(Gtk.EntryIconPosition.SECONDARY, Gtk.STOCK_DIALOG_WARNING)
384                self.current_password.set_icon_tooltip_text(Gtk.EntryIconPosition.SECONDARY, _("Wrong password"))
385                self.current_password.set_tooltip_text(_("Wrong password"))
386                self.correct_current_password = False
387            except:
388                self.current_password.set_icon_from_stock(Gtk.EntryIconPosition.SECONDARY, Gtk.STOCK_DIALOG_WARNING)
389                self.current_password.set_icon_tooltip_text(Gtk.EntryIconPosition.SECONDARY, _("Internal Error"))
390                self.current_password.set_tooltip_text(_("Internal Error"))
391                self.correct_current_password = False
392                raise
393            else:
394                self.current_password.set_icon_from_stock(Gtk.EntryIconPosition.SECONDARY, None)
395                self.current_password.set_tooltip_text("")
396                self.correct_current_password = True
397                self.check_passwords()
398
399    # Based on setPasswordStrength() in Mozilla Seamonkey, which is tri-licensed under MPL 1.1, GPL 2.0, and LGPL 2.1.
400    # Forked from Ubiquity validation.py
401    def password_strength(self, password):
402        upper = lower = digit = symbol = 0
403        for char in password:
404            if char.isdigit():
405                digit += 1
406            elif char.islower():
407                lower += 1
408            elif char.isupper():
409                upper += 1
410            else:
411                symbol += 1
412        length = len(password)
413
414        length = min(length,4)
415        digit = min(digit,3)
416        upper = min(upper,3)
417        symbol = min(symbol,3)
418        strength = (
419            ((length * 0.1) - 0.2) +
420            (digit * 0.1) +
421            (symbol * 0.15) +
422            (upper * 0.1))
423        if strength > 1:
424            strength = 1
425        if strength < 0:
426            strength = 0
427        return strength
428
429    def _on_passwords_changed(self, widget):
430        self.infobar.hide()
431        new_password = self.new_password.get_text()
432        confirm_password = self.confirm_password.get_text()
433        strength = self.password_strength(new_password)
434        if new_password != confirm_password:
435            self.confirm_password.set_icon_from_stock(Gtk.EntryIconPosition.SECONDARY, Gtk.STOCK_DIALOG_WARNING)
436            self.confirm_password.set_icon_tooltip_text(Gtk.EntryIconPosition.SECONDARY, _("Passwords do not match"))
437            self.confirm_password.set_tooltip_text(_("Passwords do not match"))
438        else:
439            self.confirm_password.set_icon_from_stock(Gtk.EntryIconPosition.SECONDARY, None)
440            self.confirm_password.set_tooltip_text("")
441        if len(new_password) < 8:
442            self.strengh_label.set_text(_("Too short"))
443            self.strengh_indicator.set_fraction(0.0)
444        elif strength < 0.6:
445            self.strengh_label.set_text(_("Weak"))
446            self.strengh_indicator.set_fraction(0.2)
447        elif strength < 0.75:
448            self.strengh_label.set_text(_("Fair"))
449            self.strengh_indicator.set_fraction(0.4)
450        elif strength < 0.9:
451            self.strengh_label.set_text(_("Good"))
452            self.strengh_indicator.set_fraction(0.6)
453        else:
454            self.strengh_label.set_text(_("Strong"))
455            self.strengh_indicator.set_fraction(1.0)
456
457        self.check_passwords()
458
459    def check_passwords(self):
460        if self.correct_current_password:
461            new_password = self.new_password.get_text()
462            confirm_password = self.confirm_password.get_text()
463            if len(new_password) >= 8 and new_password == confirm_password:
464                self.set_response_sensitive(Gtk.ResponseType.OK, True)
465            else:
466                self.set_response_sensitive(Gtk.ResponseType.OK, False)
467
468    def pam_conv(self, auth, query_list, userData):
469        resp = []
470        for i in range(len(query_list)):
471            query, type = query_list[i]
472            val = self.current_password.get_text()
473            resp.append((val, 0))
474        return resp
475