diff --git a/lms/lmsdb/bootstrap.py b/lms/lmsdb/bootstrap.py index 0c7f5891..f559759e 100644 --- a/lms/lmsdb/bootstrap.py +++ b/lms/lmsdb/bootstrap.py @@ -1,4 +1,5 @@ from typing import Any, Callable, Optional, Tuple, Type +from uuid import uuid4 from peewee import ( Database, Entity, Expression, Field, Model, OP, @@ -88,9 +89,7 @@ def get_details(table: Model, column: Field) -> Tuple[bool, str, str]: column_name = column.column_name cols = {col.name for col in db_config.database.get_columns(table_name)} - if column_name in cols: - return True, table_name, column_name - return False, table_name, column_name + return column_name in cols, table_name, column_name def _add_not_null_column( @@ -227,14 +226,32 @@ def _add_api_keys_to_users_table(table: Model, _column: Field) -> None: user.save() +def _add_uuid_to_users_table(table: Model, _column: Field) -> None: + log.info('Adding UUIDs for all users, might take some extra time...') + with db_config.database.transaction(): + for user in table: + user.uuid = uuid4() + user.save() + + def _api_keys_migration() -> bool: User = models.User _add_not_null_column(User, User.api_key, _add_api_keys_to_users_table) return True +def _uuid_migration() -> bool: + User = models.User + _add_not_null_column(User, User.uuid, _add_uuid_to_users_table) + return True + + def main(): with models.database.connection_context(): + if models.database.table_exists(models.User.__name__.lower()): + _api_keys_migration() + _uuid_migration() + models.database.create_tables(models.ALL_MODELS, safe=True) if models.Role.select().count() == 0: @@ -242,7 +259,6 @@ def main(): if models.User.select().count() == 0: models.create_demo_users() - _api_keys_migration() text_fixer.fix_texts() import_tests.load_tests_from_path('/app_dir/notebooks-tests') diff --git a/lms/lmsdb/models.py b/lms/lmsdb/models.py index 8277e1a5..eee18c6a 100644 --- a/lms/lmsdb/models.py +++ b/lms/lmsdb/models.py @@ -1,19 +1,20 @@ -from collections import Counter import enum import html import secrets import string +from collections import Counter from datetime import datetime from typing import ( Any, Dict, Iterable, List, Optional, TYPE_CHECKING, Tuple, Type, Union, cast, ) +from uuid import uuid4 from flask_babel import gettext as _ # type: ignore from flask_login import UserMixin, current_user # type: ignore from peewee import ( # type: ignore BooleanField, Case, CharField, Check, DateTimeField, ForeignKeyField, - IntegerField, JOIN, ManyToManyField, TextField, fn, + IntegerField, JOIN, ManyToManyField, TextField, UUIDField, fn, ) from playhouse.signals import Model, post_save, pre_save # type: ignore from werkzeug.security import ( @@ -138,6 +139,10 @@ class User(UserMixin, BaseModel): password = CharField() role = ForeignKeyField(Role, backref='users') api_key = CharField() + uuid = UUIDField(default=uuid4, unique=True) + + def get_id(self): + return str(self.uuid) def is_password_valid(self, password): return check_password_hash(self.password, password) @@ -201,6 +206,7 @@ def on_save_handler(model_class, instance, created): is_password_changed = not instance.password.startswith('pbkdf2:sha256') if created or is_password_changed: instance.password = generate_password_hash(instance.password) + instance.uuid = uuid4() is_api_key_changed = not instance.api_key.startswith('pbkdf2:sha256') if created or is_api_key_changed: diff --git a/lms/lmsweb/config.py.example b/lms/lmsweb/config.py.example index d1c500f5..bbd82775 100644 --- a/lms/lmsweb/config.py.example +++ b/lms/lmsweb/config.py.example @@ -63,3 +63,6 @@ LOCALE = 'en' # Limiter settings LIMITS_PER_MINUTE = 5 LIMITS_PER_HOUR = 50 + +# Change password settings +MAX_INVALID_PASSWORD_TRIES = 5 diff --git a/lms/lmsweb/forms/change_password.py b/lms/lmsweb/forms/change_password.py new file mode 100644 index 00000000..7ded2800 --- /dev/null +++ b/lms/lmsweb/forms/change_password.py @@ -0,0 +1,33 @@ +from flask import session +from flask_babel import gettext as _ # type: ignore +from flask_wtf import FlaskForm +from wtforms import PasswordField +from wtforms.validators import EqualTo, InputRequired, Length, ValidationError + +from lms.lmsweb.config import MAX_INVALID_PASSWORD_TRIES + + +class ChangePasswordForm(FlaskForm): + current_password = PasswordField( + 'Password', validators=[InputRequired(), Length(min=8)], id='password', + ) + password = PasswordField( + 'Password', validators=[InputRequired(), Length(min=8)], id='password', + ) + confirm = PasswordField( + 'Password Confirmation', validators=[ + InputRequired(), + EqualTo('password', message=_('הסיסמאות שהוקלדו אינן זהות')), + ], + ) + + def __init__(self, user, *args, **kwargs): + super(ChangePasswordForm, self).__init__(*args, **kwargs) + self.user = user + + def validate_current_password(self, field): + if session['_invalid_password_tries'] >= MAX_INVALID_PASSWORD_TRIES: + raise ValidationError(_('הזנת סיסמה שגויה מספר רב מדי של פעמים')) + if not self.user.is_password_valid(field.data): + session['_invalid_password_tries'] += 1 + raise ValidationError(_('הסיסמה הנוכחית שהוזנה שגויה')) diff --git a/lms/lmsweb/forms/reset_password.py b/lms/lmsweb/forms/reset_password.py new file mode 100644 index 00000000..49bc9afe --- /dev/null +++ b/lms/lmsweb/forms/reset_password.py @@ -0,0 +1,27 @@ +from lms.lmsweb.tools.validators import EmailNotExists +from flask_babel import gettext as _ # type: ignore +from flask_wtf import FlaskForm +from wtforms import StringField +from wtforms.fields.simple import PasswordField +from wtforms.validators import Email, EqualTo, InputRequired, Length + + +class ResetPassForm(FlaskForm): + email = StringField( + 'Email', validators=[ + InputRequired(), Email(message=_('אימייל לא תקין')), + EmailNotExists, + ], + ) + + +class RecoverPassForm(FlaskForm): + password = PasswordField( + 'Password', validators=[InputRequired(), Length(min=8)], id='password', + ) + confirm = PasswordField( + 'Password Confirmation', validators=[ + InputRequired(), + EqualTo('password', message=_('הסיסמאות שהוקלדו אינן זהות')), + ], + ) diff --git a/lms/lmsweb/tools/validators.py b/lms/lmsweb/tools/validators.py index 2f502b60..580c68d9 100644 --- a/lms/lmsweb/tools/validators.py +++ b/lms/lmsweb/tools/validators.py @@ -19,3 +19,11 @@ def UniqueEmailRequired( email_exists = User.get_or_none(User.mail_address == field.data) if email_exists: raise ValidationError(_('האימייל כבר נמצא בשימוש')) + + +def EmailNotExists( + _form: 'ResetPassForm', field: StringField, # type: ignore # NOQA: F821 +) -> None: + email_exists = User.get_or_none(User.mail_address == field.data) + if not email_exists: + raise ValidationError(_('האימייל לא רשום במערכת')) diff --git a/lms/lmsweb/translations/en/LC_MESSAGES/messages.po b/lms/lmsweb/translations/en/LC_MESSAGES/messages.po index 0c35ce13..81b6ee74 100644 --- a/lms/lmsweb/translations/en/LC_MESSAGES/messages.po +++ b/lms/lmsweb/translations/en/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: lmsweb-1.0\n" "Report-Msgid-Bugs-To: bugs@mesicka.com\n" -"POT-Creation-Date: 2021-09-12 15:10+0300\n" +"POT-Creation-Date: 2021-09-15 18:29+0300\n" "PO-Revision-Date: 2020-09-16 18:29+0300\n" "Last-Translator: Or Ronai\n" "Language: en\n" @@ -18,7 +18,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.9.1\n" -#: lmsdb/models.py:698 +#: lmsdb/models.py:703 msgid "כישלון חמור" msgstr "Fatal error" @@ -45,34 +45,56 @@ msgstr "The automatic checker couldn't run your code." msgid "אחי, בדקת את הקוד שלך?" msgstr "Bro, did you check your code?" -#: lmsweb/views.py:122 +#: lmsweb/views.py:131 msgid "לא ניתן להירשם כעת" msgstr "Can not register now" -#: lmsweb/views.py:139 +#: lmsweb/views.py:152 msgid "ההרשמה בוצעה בהצלחה" msgstr "Registration successfully" -#: lmsweb/views.py:161 +#: lmsweb/views.py:175 msgid "קישור האימות פג תוקף, קישור חדש נשלח אל תיבת המייל שלך" msgstr "The confirmation link is expired, new link has been sent to your email" -#: lmsweb/views.py:174 +#: lmsweb/views.py:188 #, fuzzy msgid "המשתמש שלך אומת בהצלחה, כעת אתה יכול להתחבר למערכת" msgstr "Your user has been successfully confirmed, you can now login" -#: lmsweb/forms/register.py:14 -msgid "אימייל לא תקין" -msgstr "Invalid email" +#: lmsweb/views.py:213 lmsweb/views.py:273 +#, fuzzy +msgid "הסיסמה שלך שונתה בהצלחה" +msgstr "Your password has successfully changed" + +#: lmsweb/views.py:229 +msgid "קישור לאיפוס הסיסמה נשלח בהצלחה" +msgstr "Password reset link has successfully sent" + +#: lmsweb/views.py:250 +msgid "קישור איפוס הסיסמה פג תוקף" +msgstr "Reset password link is expired" -#: lmsweb/forms/register.py:32 +#: lmsweb/forms/change_password.py:20 lmsweb/forms/register.py:32 +#: lmsweb/forms/reset_password.py:25 msgid "הסיסמאות שהוקלדו אינן זהות" msgstr "The passwords are not identical" -#: lmsweb/tools/registration.py:105 +#: lmsweb/forms/change_password.py:30 +msgid "הזנת סיסמה שגויה מספר רב מדי של פעמים" +msgstr "Invalid old password has been inserted too many times" + +#: lmsweb/forms/change_password.py:33 +msgid "הסיסמה הנוכחית שהוזנה שגויה" +msgstr "Invalid current password" + +#: lmsweb/forms/register.py:14 lmsweb/forms/reset_password.py:12 +msgid "אימייל לא תקין" +msgstr "Invalid email" + +#: lmsweb/tools/registration.py:109 msgid "מערכת הגשת התרגילים" -msgstr "Exercuse submission system" +msgstr "Exercise submission system" #: lmsweb/tools/validators.py:13 msgid "שם המשתמש כבר נמצא בשימוש" @@ -82,15 +104,10 @@ msgstr "The username already in use" msgid "האימייל כבר נמצא בשימוש" msgstr "The email already in use" -#: models/register.py:20 -#, python-format -msgid "מייל אימות - %(site_name)s" -msgstr "Confirmation mail - %(site_name)s" - -#: models/register.py:25 -#, fuzzy, python-format -msgid "שלום %(fullname)s,\nלינק האימות שלך למערכת הוא: %(link)s" -msgstr "Hello %(fullname)s,\n Your confirmation link is: %(link)s" +#: lmsweb/tools/validators.py:29 +#, fuzzy +msgid "האימייל לא רשום במערכת" +msgstr "Invalid email" #: models/solutions.py:50 #, python-format @@ -107,17 +124,19 @@ msgstr "%(checker)s replied for \"%(subject)s\"." msgid "הפתרון שלך לתרגיל \"%(subject)s\" נבדק." msgstr "Your solution for the \"%(subject)s\" exercise has been checked." -#: models/users.py:25 +#: models/users.py:28 #, fuzzy msgid "שם המשתמש או הסיסמה שהוזנו לא תקינים" msgstr "Invalid username or password" -#: models/users.py:27 +#: models/users.py:30 #, fuzzy msgid "עליך לאשר את מייל האימות" msgstr "You have to confirm your registration with the link sent to your email" -#: templates/banned.html:8 templates/login.html:7 templates/signup.html:8 +#: templates/banned.html:8 templates/login.html:7 +#: templates/recover-password.html:8 templates/reset-password.html:8 +#: templates/signup.html:8 msgid "תמונת הפרופיל של קורס פייתון" msgstr "Profile picture of the Python Course" @@ -133,6 +152,35 @@ msgstr "For more details please contact the management team." msgid "מערכת הגשת תרגילים לקורס פייתון" msgstr "Exercise submission system for the Python Course" +#: templates/change-password.html:8 +#, fuzzy +msgid "שינוי סיסמה" +msgstr "Change Password" + +#: templates/change-password.html:10 +#, fuzzy +msgid "הזינו סיסמה ישנה וסיסמה חדשה לצורך שינוי הסיסמה:" +msgstr "Insert current password and new password for changing it:" + +#: templates/change-password.html:13 +msgid "סיסמה נוכחית" +msgstr "Current password" + +#: templates/change-password.html:14 templates/recover-password.html:14 +#, fuzzy +msgid "סיסמה חדשה" +msgstr "Password" + +#: templates/change-password.html:15 templates/recover-password.html:15 +#, fuzzy +msgid "אימות סיסמה חדשה" +msgstr "Password Confirmation" + +#: templates/change-password.html:18 templates/user.html:19 +#, fuzzy +msgid "שנה סיסמה" +msgstr "Change Password" + #: templates/exercises.html:8 msgid "תרגילים" msgstr "Exercises" @@ -181,7 +229,11 @@ msgstr "Password" msgid "התחבר" msgstr "Login" -#: templates/login.html:39 templates/signup.html:22 +#: templates/login.html:36 +msgid "שכחת את הסיסמה?" +msgstr "Forgot your password?" + +#: templates/login.html:40 templates/signup.html:22 msgid "הירשם" msgstr "Register" @@ -221,6 +273,38 @@ msgstr "Check Exercises" msgid "התנתקות" msgstr "Logout" +#: templates/recover-password.html:9 templates/reset-password.html:9 +#, fuzzy +msgid "איפוס סיסמה" +msgstr "Reset Password" + +#: templates/recover-password.html:11 +#, fuzzy +msgid "הזינו סיסמה לצורך שינוי הסיסמה:" +msgstr "Insert password for changing it:" + +#: templates/recover-password.html:18 +#, fuzzy +msgid "אפס סיסמה" +msgstr "Reset Password" + +#: templates/reset-password.html:11 +#, fuzzy +msgid "הזינו אימייל לצורך שליחת קישור לאיפוס הסיסמה:" +msgstr "Insert your email for getting link to reset it:" + +#: templates/reset-password.html:14 templates/signup.html:15 +msgid "כתובת אימייל" +msgstr "Email Address" + +#: templates/reset-password.html:15 +msgid "שלח מייל איפוס סיסמה" +msgstr "Send Reset Password Link" + +#: templates/reset-password.html:18 templates/signup.html:25 +msgid "חזרה לדף ההתחברות" +msgstr "Back to login page" + #: templates/signup.html:9 #, fuzzy msgid "הרשמה" @@ -230,10 +314,6 @@ msgstr "Registration" msgid "הזינו אימייל וסיסמה לצורך רישום למערכת:" msgstr "Insert your email and password for registration:" -#: templates/signup.html:15 -msgid "כתובת אימייל" -msgstr "Email Address" - #: templates/signup.html:17 msgid "שם מלא" msgstr "Full Name" @@ -243,10 +323,6 @@ msgstr "Full Name" msgid "אימות סיסמה" msgstr "Password Confirmation" -#: templates/signup.html:25 -msgid "חזרה לדף ההתחברות" -msgstr "Back to login page" - #: templates/status.html:7 msgid "חמ\"ל תרגילים" msgstr "Exercises operations room" @@ -308,54 +384,58 @@ msgid "דואר אלקטרוני:" msgstr "Email Address:" #: templates/user.html:16 +msgid "פעולות:" +msgstr "Actions:" + +#: templates/user.html:24 msgid "תרגילים שהוגשו:" msgstr "Exercises Submitted:" -#: templates/user.html:21 +#: templates/user.html:29 msgid "שם תרגיל" msgstr "Exercise name" -#: templates/user.html:22 +#: templates/user.html:30 msgid "מצב הגשה" msgstr "Submission status" -#: templates/user.html:23 +#: templates/user.html:31 msgid "הגשה" msgstr "Submission" -#: templates/user.html:24 +#: templates/user.html:32 msgid "בודק" msgstr "Checker" -#: templates/user.html:33 +#: templates/user.html:41 msgid "נבדק" msgstr "Checked" -#: templates/user.html:33 +#: templates/user.html:41 msgid "הוגש" msgstr "Submitted" -#: templates/user.html:33 +#: templates/user.html:41 msgid "לא הוגש" msgstr "Not submitted" -#: templates/user.html:44 +#: templates/user.html:52 msgid "פתקיות:" msgstr "Notes:" -#: templates/user.html:49 templates/user.html:51 +#: templates/user.html:57 templates/user.html:59 msgid "פתקית חדשה" msgstr "New Note" -#: templates/user.html:55 +#: templates/user.html:63 msgid "תרגיל משויך:" msgstr "Exercise:" -#: templates/user.html:64 +#: templates/user.html:72 msgid "רמת פרטיות:" msgstr "Privacy Level:" -#: templates/user.html:70 +#: templates/user.html:78 msgid "הוסף פתקית" msgstr "Add Note" @@ -426,3 +506,33 @@ msgstr "Checker comments" #: templates/view.html:127 msgid "סיימתי לבדוק!" msgstr "Done Checking!" + +#: utils/mail.py:25 +#, python-format +msgid "מייל אימות - %(site_name)s" +msgstr "Confirmation mail - %(site_name)s" + +#: utils/mail.py:30 +#, fuzzy, python-format +msgid "שלום %(fullname)s,\nלינק האימות שלך למערכת הוא: %(link)s" +msgstr "Hello %(fullname)s,\n Your confirmation link is: %(link)s" + +#: utils/mail.py:40 +#, fuzzy, python-format +msgid "מייל איפוס סיסמה - %(site_name)s" +msgstr "Reset password mail - %(site_name)s" + +#: utils/mail.py:45 +#, fuzzy, python-format +msgid "שלום %(fullname)s,\nלינק לצורך איפוס הסיסמה שלך הוא: %(link)s" +msgstr "Hello %(fullname)s,\nYour reset password link is: %(link)s" + +#: utils/mail.py:54 +#, fuzzy, python-format +msgid "שינוי סיסמה - %(site_name)s" +msgstr "Changing password - %(site_name)s" + +#: utils/mail.py:56 +#, python-format +msgid "שלום %(fullname)s. הסיסמה שלך באתר %(site_name)s שונתה.\nאם אתה לא עשית את זה צור קשר עם הנהלת האתר.\nכתובת המייל: %(site_mail)s" +msgstr "Hello %(fullname)s. Your password in %(site_name)s site has been changed.\nIf you didn't do it please contact with the site management.\nMail address: %(site_mail)s" diff --git a/lms/lmsweb/views.py b/lms/lmsweb/views.py index 1337159b..49ffdc3f 100644 --- a/lms/lmsweb/views.py +++ b/lms/lmsweb/views.py @@ -2,8 +2,8 @@ import arrow # type: ignore from flask import ( - jsonify, make_response, render_template, - request, send_from_directory, url_for, + jsonify, make_response, render_template, request, + send_from_directory, session, url_for, ) from flask_babel import gettext as _ # type: ignore from flask_limiter.util import get_remote_address # type: ignore @@ -26,7 +26,9 @@ CONFIRMATION_TIME, LANGUAGES, LIMITS_PER_HOUR, LIMITS_PER_MINUTE, LOCALE, MAX_UPLOAD_SIZE, ) +from lms.lmsweb.forms.change_password import ChangePasswordForm from lms.lmsweb.forms.register import RegisterForm +from lms.lmsweb.forms.reset_password import RecoverPassForm, ResetPassForm from lms.lmsweb.manifest import MANIFEST from lms.lmsweb.redirections import ( PERMISSIVE_CORS, get_next_url, login_manager, @@ -38,13 +40,16 @@ FileSizeError, ForbiddenPermission, LmsError, UnauthorizedError, UploadError, fail, ) -from lms.models.register import SERIALIZER, send_confirmation_mail -from lms.models.users import auth, retrieve_salt +from lms.models.users import SERIALIZER, auth, retrieve_salt from lms.utils.consts import RTL_LANGUAGES from lms.utils.files import ( get_language_name_by_extension, get_mime_type_by_extention, ) from lms.utils.log import log +from lms.utils.mail import ( + send_change_password_mail, send_confirmation_mail, + send_reset_password_mail, +) HIGH_ROLES = {str(RoleOptions.STAFF), str(RoleOptions.ADMINISTRATOR)} @@ -75,8 +80,8 @@ def _db_close(exc): @login_manager.user_loader -def load_user(user_id): - return User.get_or_none(id=user_id) +def load_user(uuid): + return User.get_or_none(uuid=uuid) @webapp.errorhandler(429) @@ -110,6 +115,7 @@ def login(login_message: Optional[str] = None): return redirect(url_for('login', **error_details)) else: login_user(user) + session['_invalid_password_tries'] = 0 return get_next_url(next_page) return render_template('login.html', login_message=login_message) @@ -178,6 +184,83 @@ def confirm_email(user_id: int, token: str): )) +@webapp.route('/change-password', methods=['GET', 'POST']) +@login_required +def change_password(): + user = User.get(User.id == current_user.id) + + form = ChangePasswordForm(user) + if not form.validate_on_submit(): + return render_template('change-password.html', form=form) + + user.password = form.password.data + user.save() + logout_user() + send_change_password_mail(user) + return redirect(url_for( + 'login', login_message=( + _('הסיסמה שלך שונתה בהצלחה'), + ), + )) + + +@webapp.route('/reset-password', methods=['GET', 'POST']) +@limiter.limit(f'{LIMITS_PER_MINUTE}/minute;{LIMITS_PER_HOUR}/hour') +def reset_password(): + form = ResetPassForm() + if not form.validate_on_submit(): + return render_template('reset-password.html', form=form) + + user = User.get(User.mail_address == form.email.data) + + send_reset_password_mail(user) + return redirect(url_for( + 'login', login_message=_('קישור לאיפוס הסיסמה נשלח בהצלחה'), + )) + + +@webapp.route( + '/recover-password//', methods=['GET', 'POST'], +) +@limiter.limit(f'{LIMITS_PER_MINUTE}/minute;{LIMITS_PER_HOUR}/hour') +def recover_password(user_id: int, token: str): + user = User.get_or_none(User.id == user_id) + if user is None: + return fail(404, 'The authentication code is invalid.') + + try: + SERIALIZER.loads( + token, salt=retrieve_salt(user), max_age=CONFIRMATION_TIME, + ) + + except SignatureExpired: + return redirect(url_for( + 'login', login_message=( + _('קישור איפוס הסיסמה פג תוקף'), + ), + )) + except BadSignature: + return fail(404, 'The authentication code is invalid.') + + else: + return recover_password_check(user, token) + + +def recover_password_check(user: User, token: str): + form = RecoverPassForm() + if not form.validate_on_submit(): + return render_template( + 'recover-password.html', form=form, id=user.id, token=token, + ) + user.password = form.password.data + user.save() + return redirect(url_for( + 'login', login_message=( + _('הסיסמה שלך שונתה בהצלחה'), + ), + )) + + @webapp.route('/logout') @login_required def logout(): diff --git a/lms/models/register.py b/lms/models/register.py deleted file mode 100644 index f19937cb..00000000 --- a/lms/models/register.py +++ /dev/null @@ -1,30 +0,0 @@ -from flask import url_for -from flask_babel import gettext as _ # type: ignore -from flask_mail import Message # type: ignore -from itsdangerous import URLSafeTimedSerializer - -from lms.lmsdb.models import User -from lms.lmsweb import config, webapp, webmail -from lms.models.users import retrieve_salt - - -SERIALIZER = URLSafeTimedSerializer(config.SECRET_KEY) - - -def generate_confirmation_token(user: User) -> str: - return SERIALIZER.dumps(user.mail_address, salt=retrieve_salt(user)) - - -def send_confirmation_mail(user: User) -> None: - token = generate_confirmation_token(user) - subject = _('מייל אימות - %(site_name)s', site_name=config.SITE_NAME) - msg = Message(subject, recipients=[user.mail_address]) - link = url_for( - 'confirm_email', user_id=user.id, token=token, _external=True, - ) - msg.body = _( - 'שלום %(fullname)s,\nלינק האימות שלך למערכת הוא: %(link)s', - fullname=user.fullname, link=link, - ) - if not webapp.config.get('TESTING'): - webmail.send(msg) diff --git a/lms/models/users.py b/lms/models/users.py index bb2720a6..6f060396 100644 --- a/lms/models/users.py +++ b/lms/models/users.py @@ -1,13 +1,16 @@ import re from flask_babel import gettext as _ # type: ignore +from itsdangerous import URLSafeTimedSerializer from lms.lmsdb.models import User +from lms.lmsweb import config from lms.models.errors import ( ForbiddenPermission, UnauthorizedError, UnhashedPasswordError, ) +SERIALIZER = URLSafeTimedSerializer(config.SECRET_KEY) HASHED_PASSWORD = re.compile(r'^pbkdf2.+?\$(?P.+?)\$(?P.+)') @@ -26,3 +29,7 @@ def auth(username: str, password: str) -> User: elif user.role.is_unverified: raise ForbiddenPermission(_('עליך לאשר את מייל האימות'), 403) return user + + +def generate_user_token(user: User) -> str: + return SERIALIZER.dumps(user.mail_address, salt=retrieve_salt(user)) diff --git a/lms/static/my.css b/lms/static/my.css index e2539d0d..886ed538 100644 --- a/lms/static/my.css +++ b/lms/static/my.css @@ -47,7 +47,10 @@ a { } #login-container, -#signup-container { +#signup-container, +#change-password-container, +#reset-password-container, +#recover-password-container { height: 100%; align-items: center; display: flex; @@ -55,7 +58,10 @@ a { } #login, -#signup { +#signup, +#change-password, +#reset-password, +#recover-password { margin: auto; max-width: 420px; padding: 15px; @@ -63,12 +69,14 @@ a { } #login-logo, -#signup-logo { +#signup-logo, +#reset-password-logo, +#recover-password-logo { margin-bottom: 1rem; } #login-message-box, -#signup-errors { +#form-errors { background: #f1c8c8; color: #860606; } @@ -818,7 +826,7 @@ code .grader-add .fa { width: 80vw; } -#user .user-details { +#user .user-actions { margin-bottom: 5em; } diff --git a/lms/templates/_formhelpers.html b/lms/templates/_formhelpers.html index 4175e804..85aa7a7d 100644 --- a/lms/templates/_formhelpers.html +++ b/lms/templates/_formhelpers.html @@ -2,7 +2,7 @@
{{ field(class=cls, **kwargs) | safe }} -
+
{% if field.errors %} {% for error in field.errors %} {{ error }} diff --git a/lms/templates/change-password.html b/lms/templates/change-password.html new file mode 100644 index 00000000..61c5ef71 --- /dev/null +++ b/lms/templates/change-password.html @@ -0,0 +1,22 @@ +{% extends 'base.html' %} +{% from "_formhelpers.html" import render_field %} + +{% block page_content %} +
+
+
+

{{ _('שינוי סיסמה') }}

+

+ {{ _('הזינו סיסמה ישנה וסיסמה חדשה לצורך שינוי הסיסמה:') }} +

+
+ {{ render_field(form.current_password, cls="form-control form-control-lg", placeholder=_('סיסמה נוכחית')) }} + {{ render_field(form.password, cls="form-control form-control-lg", placeholder=_('סיסמה חדשה')) }} + {{ render_field(form.confirm, cls="form-control form-control-lg", placeholder=_('אימות סיסמה חדשה')) }} + + +
+
+
+
+{% endblock %} diff --git a/lms/templates/login.html b/lms/templates/login.html index b57f9238..f76945de 100644 --- a/lms/templates/login.html +++ b/lms/templates/login.html @@ -17,7 +17,7 @@

{{ _('התחברות') }}

{% endif %} -
+
@@ -33,10 +33,11 @@

{{ _('התחברות') }}

+ {{ _('שכחת את הסיסמה?') }} {% if config.REGISTRATION_OPEN %}
- {{ _('הירשם') }} + {{ _('הירשם') }} {% endif %}
diff --git a/lms/templates/recover-password.html b/lms/templates/recover-password.html new file mode 100644 index 00000000..a805977c --- /dev/null +++ b/lms/templates/recover-password.html @@ -0,0 +1,22 @@ +{% extends 'base.html' %} +{% from "_formhelpers.html" import render_field %} + +{% block page_content %} +
+
+
+ +

{{ _('איפוס סיסמה') }}

+

+ {{ _('הזינו סיסמה לצורך שינוי הסיסמה:') }} +

+
+ {{ render_field(form.password, cls="form-control form-control-lg", placeholder=_('סיסמה חדשה')) }} + {{ render_field(form.confirm, cls="form-control form-control-lg", placeholder=_('אימות סיסמה חדשה')) }} + + +
+
+
+
+{% endblock %} diff --git a/lms/templates/reset-password.html b/lms/templates/reset-password.html new file mode 100644 index 00000000..98464480 --- /dev/null +++ b/lms/templates/reset-password.html @@ -0,0 +1,22 @@ +{% extends 'base.html' %} +{% from "_formhelpers.html" import render_field %} + +{% block page_content %} +
+
+
+ +

{{ _('איפוס סיסמה') }}

+

+ {{ _('הזינו אימייל לצורך שליחת קישור לאיפוס הסיסמה:') }} +

+
+ {{ render_field(form.email, cls="form-control form-control-lg", placeholder=_('כתובת אימייל')) }} + +
+
+ {{ _('חזרה לדף ההתחברות') }} +
+
+
+{% endblock %} diff --git a/lms/templates/signup.html b/lms/templates/signup.html index b888068b..226478fa 100644 --- a/lms/templates/signup.html +++ b/lms/templates/signup.html @@ -11,7 +11,7 @@

{{ _('הרשמה') }}

{{ _('ברוכים הבאים למערכת התרגילים!') }}
{{ _('הזינו אימייל וסיסמה לצורך רישום למערכת:') }}

-
+ {{ render_field(form.email, cls="form-control form-control-lg", placeholder=_('כתובת אימייל')) }} {{ render_field(form.username, cls="form-control form-control-lg", placeholder=_('שם משתמש')) }} {{ render_field(form.fullname, cls="form-control form-control-lg", placeholder=_('שם מלא')) }} @@ -21,7 +21,7 @@

{{ _('הרשמה') }}

-
+
{{ _('חזרה לדף ההתחברות') }}
diff --git a/lms/templates/user.html b/lms/templates/user.html index 8523bd80..2a1c46fb 100644 --- a/lms/templates/user.html +++ b/lms/templates/user.html @@ -12,6 +12,14 @@

{{ _('פרטי משתמש:') }}

  • {{ _('דואר אלקטרוני:') }} {{ user.mail_address | e }}
  • +

    {{ _('תרגילים שהוגשו:') }}

    diff --git a/lms/utils/mail.py b/lms/utils/mail.py new file mode 100644 index 00000000..c1dddf0d --- /dev/null +++ b/lms/utils/mail.py @@ -0,0 +1,48 @@ +from flask import url_for +from flask_babel import gettext as _ # type: ignore +from flask_mail import Message # type: ignore + +from lms.lmsdb.models import User +from lms.lmsweb import config, webmail +from lms.models.users import generate_user_token + + +def send_confirmation_mail(user: User) -> Message: + token = generate_user_token(user) + subject = _('מייל אימות - %(site_name)s', site_name=config.SITE_NAME) + msg = Message(subject, recipients=[user.mail_address]) + link = url_for( + 'confirm_email', user_id=user.id, token=token, _external=True, + ) + msg.body = _( + 'שלום %(fullname)s,\nלינק האימות שלך למערכת הוא: %(link)s', + fullname=user.fullname, link=link, + ) + webmail.send(msg) + + +def send_reset_password_mail(user: User) -> Message: + token = generate_user_token(user) + subject = _('מייל איפוס סיסמה - %(site_name)s', site_name=config.SITE_NAME) + msg = Message(subject, recipients=[user.mail_address]) + link = url_for( + 'recover_password', user_id=user.id, token=token, _external=True, + ) + msg.body = _( + 'שלום %(fullname)s,\nלינק לצורך איפוס הסיסמה שלך הוא: %(link)s', + fullname=user.fullname, link=link, + ) + webmail.send(msg) + + +def send_change_password_mail(user: User) -> Message: + subject = _('שינוי סיסמה - %(site_name)s', site_name=config.SITE_NAME) + msg = Message(subject, recipients=[user.mail_address]) + msg.body = _( + 'שלום %(fullname)s. הסיסמה שלך באתר %(site_name)s שונתה.\n' + 'אם אתה לא עשית את זה צור קשר עם הנהלת האתר.\n' + 'כתובת המייל: %(site_mail)s', + fullname=user.fullname, site_name=config.SITE_NAME, + site_mail=config.MAIL_DEFAULT_SENDER, + ) + webmail.send(msg) diff --git a/tests/conftest.py b/tests/conftest.py index f08c3a6b..a2a7afaf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,7 @@ from _pytest.logging import caplog as _caplog # NOQA: F401 from flask import template_rendered from flask.testing import FlaskClient +from flask_mail import Mail from loguru import logger from peewee import SqliteDatabase import pytest @@ -87,6 +88,7 @@ def webapp_configurations(): @pytest.fixture(autouse=True, scope='session') def disable_mail_sending(): webapp.config['TESTING'] = True + webmail = Mail(webapp) @pytest.fixture(autouse=True, scope='session') @@ -133,15 +135,63 @@ def logout_user(client: FlaskClient) -> None: client.get('/logout', follow_redirects=True) +def signup_client_user( + client: FlaskClient, email: str, username: str, fullname: str, + password: str, confirm_password: str, +): + return client.post('/signup', data={ + 'email': email, + 'username': username, + 'fullname': fullname, + 'password': password, + 'confirm': confirm_password, + }, follow_redirects=True) + + +def login_client_user(client: FlaskClient, username: str, password: str): + return client.post('/login', data={ + 'username': username, + 'password': password, + }, follow_redirects=True) + + +def change_client_password( + client: FlaskClient, current_password: str, + new_password: str, confirm_password: str, +): + return client.post('/change-password', data={ + 'current_password': current_password, + 'password': new_password, + 'confirm': confirm_password, + }, follow_redirects=True) + + +def reset_client_password(client: FlaskClient, email: str): + return client.post('/reset-password', data={ + 'email': email, + }, follow_redirects=True) + + +def recover_client_password( + client: FlaskClient, user_id: int, token: str, + password: str, confirm_password: str, +): + return client.post(f'/recover-password/{user_id}/{token}', data={ + 'password': password, + 'confirm': confirm_password, + }, follow_redirects=True) + + def create_user( - role_name: str = RoleOptions.STUDENT.value, - index: int = 1, + role_name: str = RoleOptions.STUDENT.value, index: int = 1, ) -> User: + username = f'{role_name}-{index}' + password = 'fake pass' return User.create( # NOQA: S106 - username=f'{role_name}-{index}', + username=username, fullname=f'A{role_name}', mail_address=f'so-{role_name}-{index}@mail.com', - password='fake pass', + password=password, api_key='fake key', role=Role.by_name(role_name), ) @@ -191,11 +241,13 @@ def student_user(): @pytest.fixture() def admin_user(): admin_role = Role.get(Role.name == RoleOptions.ADMINISTRATOR.value) + username = 'Yam' + password = 'fake pass' return User.create( # NOQA: B106, S106 - username='Yam', + username=username, fullname='Buya', mail_address='mymail@mail.com', - password='fake pass', + password=password, api_key='fake key', role=admin_role, ) diff --git a/tests/test_login.py b/tests/test_login.py index 5a6f8e91..7eb612d9 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -1,24 +1,19 @@ from flask.testing import FlaskClient from lms.lmsdb.models import User +from tests import conftest class TestLogin: @staticmethod def test_login_password_fail(client: FlaskClient, student_user: User): - client.post('/login', data={ - 'username': student_user.username, - 'password': 'wrong_pass', - }, follow_redirects=True) + conftest.login_client_user(client, student_user.username, 'wrong_pass') fail_login_response = client.get('/exercises') assert fail_login_response.status_code == 302 @staticmethod def test_login_username_fail(client: FlaskClient): - client.post('/login', data={ - 'username': 'wrong_user', - 'password': 'fake pass', - }, follow_redirects=True) + conftest.login_client_user(client, 'wrong user', 'fake pass') fail_login_response = client.get('/exercises') assert fail_login_response.status_code == 302 @@ -26,10 +21,9 @@ def test_login_username_fail(client: FlaskClient): def test_login_unverified_user( client: FlaskClient, unverified_user: User, captured_templates, ): - client.post('/login', data={ - 'username': unverified_user.username, - 'password': 'fake pass', - }, follow_redirects=True) + conftest.login_client_user( + client, unverified_user.username, 'fake pass', + ) template, _ = captured_templates[-1] assert template.name == 'login.html' @@ -38,9 +32,6 @@ def test_login_unverified_user( @staticmethod def test_login_success(client: FlaskClient, student_user: User): - client.post('/login', data={ - 'username': student_user.username, - 'password': 'fake pass', - }, follow_redirects=True) + conftest.login_client_user(client, student_user.username, 'fake pass') success_login_response = client.get('/exercises') assert success_login_response.status_code == 200 diff --git a/tests/test_registration.py b/tests/test_registration.py index f7b034ae..572feae2 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -5,7 +5,7 @@ from lms.lmsweb.config import CONFIRMATION_TIME from lms.lmsdb.models import User -from lms.models.register import generate_confirmation_token +from lms.models.users import generate_user_token from tests import conftest @@ -14,20 +14,16 @@ class TestRegistration: def test_invalid_username( client: FlaskClient, student_user: User, captured_templates, ): - client.post('/signup', data={ - 'email': 'some_name@mail.com', - 'username': student_user.username, - 'fullname': 'some_name', - 'password': 'some_password', - 'confirm': 'some_password', - }, follow_redirects=True) + conftest.signup_client_user( + client, 'some_name@mail.com', student_user.username, + 'some_name', 'some_password', 'some_password', + ) template, _ = captured_templates[-1] assert template.name == 'signup.html' - client.post('/login', data={ - 'username': student_user.username, - 'password': 'some_password', - }, follow_redirects=True) + conftest.login_client_user( + client, student_user.username, 'some_password', + ) fail_login_response = client.get('/exercises') assert fail_login_response.status_code == 302 @@ -35,6 +31,10 @@ def test_invalid_username( def test_invalid_email( client: FlaskClient, student_user: User, captured_templates, ): + conftest.signup_client_user( + client, student_user.mail_address, 'some_user', + 'some_name', 'some_password', 'some_password', + ) client.post('/signup', data={ 'email': student_user.mail_address, 'username': 'some_user', @@ -45,51 +45,16 @@ def test_invalid_email( template, _ = captured_templates[-1] assert template.name == 'signup.html' - client.post('/login', data={ - 'username': 'some_user', - 'password': 'some_password', - }, follow_redirects=True) - fail_login_response = client.get('/exercises') - assert fail_login_response.status_code == 302 - - @staticmethod - def test_successful_registration(client: FlaskClient, captured_templates): - client.post('/signup', data={ - 'email': 'some_user123@mail.com', - 'username': 'some_user', - 'fullname': 'some_name', - 'password': 'some_password', - 'confirm': 'some_password', - }, follow_redirects=True) - template, _ = captured_templates[-1] - assert template.name == 'login.html' - - client.post('/login', data={ - 'username': 'some_user', - 'password': 'some_password', - }, follow_redirects=True) + conftest.login_client_user(client, 'some_user', 'some_password') fail_login_response = client.get('/exercises') assert fail_login_response.status_code == 302 - user = User.get_or_none(User.username == 'some_user') - token = generate_confirmation_token(user) - client.get(f'/confirm-email/{user.id}/{token}', follow_redirects=True) - client.post('/login', data={ - 'username': 'some_user', - 'password': 'some_password', - }, follow_redirects=True) - success_login_response = client.get('/exercises') - assert success_login_response.status_code == 200 - @staticmethod def test_bad_token_or_id(client: FlaskClient): - client.post('/signup', data={ - 'email': 'some_user123@mail.com', - 'username': 'some_user', - 'fullname': 'some_name', - 'password': 'some_password', - 'confirm': 'some_password', - }, follow_redirects=True) + conftest.signup_client_user( + client, 'some_user123@mail.com', 'some_user', + 'some_name', 'some_password', 'some_password', + ) user = User.get_or_none(User.username == 'some_user') bad_token = 'fake-token43@$@' # noqa: S105 fail_confirm_response = client.get( @@ -105,15 +70,12 @@ def test_bad_token_or_id(client: FlaskClient): @staticmethod def test_use_token_twice(client: FlaskClient): - client.post('/signup', data={ - 'email': 'some_user123@mail.com', - 'username': 'some_user', - 'fullname': 'some_name', - 'password': 'some_password', - 'confirm': 'some_password', - }, follow_redirects=True) + conftest.signup_client_user( + client, 'some_user123@mail.com', 'some_user', + 'some_name', 'some_password', 'some_password', + ) user = User.get_or_none(User.username == 'some_user') - token = generate_confirmation_token(user) + token = generate_user_token(user) success_token_response = client.get( f'/confirm-email/{user.id}/{token}', follow_redirects=True, ) @@ -126,49 +88,57 @@ def test_use_token_twice(client: FlaskClient): @staticmethod def test_expired_token(client: FlaskClient): - client.post('/signup', data={ - 'email': 'some_user123@mail.com', - 'username': 'some_user', - 'fullname': 'some_name', - 'password': 'some_password', - 'confirm': 'some_password', - }, follow_redirects=True) + conftest.signup_client_user( + client, 'some_user123@mail.com', 'some_user', + 'some_name', 'some_password', 'some_password', + ) user = User.get_or_none(User.username == 'some_user') - token = generate_confirmation_token(user) + token = generate_user_token(user) fake_time = time.time() + CONFIRMATION_TIME + 1 with patch('time.time', Mock(return_value=fake_time)): client.get( f'/confirm-email/{user.id}/{token}', follow_redirects=True, ) - client.post('/login', data={ - 'username': 'some_user', - 'password': 'some_password', - }, follow_redirects=True) + conftest.login_client_user(client, 'some_user', 'some_password') fail_login_response = client.get('/exercises') assert fail_login_response.status_code == 302 - token = generate_confirmation_token(user) + token = generate_user_token(user) client.get( f'/confirm-email/{user.id}/{token}', follow_redirects=True, ) - client.post('/login', data={ - 'username': 'some_user', - 'password': 'some_password', - }, follow_redirects=True) + conftest.login_client_user(client, 'some_user', 'some_password') success_login_response = client.get('/exercises') assert success_login_response.status_code == 200 + @staticmethod + def test_successful_registration(client: FlaskClient, captured_templates): + conftest.signup_client_user( + client, 'some_user123@mail.com', 'some_user', + 'some_name', 'some_password', 'some_password', + ) + template, _ = captured_templates[-1] + assert template.name == 'login.html' + + conftest.login_client_user(client, 'some_user', 'some_password') + fail_login_response = client.get('/exercises') + assert fail_login_response.status_code == 302 + + user = User.get_or_none(User.username == 'some_user') + token = generate_user_token(user) + client.get(f'/confirm-email/{user.id}/{token}', follow_redirects=True) + conftest.login_client_user(client, 'some_user', 'some_password') + success_login_response = client.get('/exercises') + assert success_login_response.status_code == 200 + @staticmethod def test_registartion_closed(client: FlaskClient, captured_templates): conftest.disable_registration() - client.post('/signup', data={ - 'email': 'some_user123@mail.com', - 'username': 'some_user', - 'fullname': 'some_name', - 'password': 'some_password', - 'confirm': 'some_password', - }, follow_redirects=True) + conftest.signup_client_user( + client, 'some_user123@mail.com', 'some_user', + 'some_name', 'some_password', 'some_password', + ) user = User.get_or_none(User.username == 'some_user') assert user is None diff --git a/tests/test_users.py b/tests/test_users.py index 043570ca..ea510e23 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -1,6 +1,11 @@ +import time +from unittest.mock import Mock, patch + from flask.testing import FlaskClient from lms.lmsdb.models import User +from lms.lmsweb.config import CONFIRMATION_TIME, MAX_INVALID_PASSWORD_TRIES +from lms.models.users import generate_user_token from tests import conftest @@ -63,8 +68,111 @@ def test_logout(student_user: User): @staticmethod def test_banned_user(client: FlaskClient, banned_user: User): - login_response = client.post('/login', data={ - 'username': banned_user.username, - 'password': 'fake pass', - }, follow_redirects=True) + login_response = conftest.login_client_user( + client, banned_user.username, 'fake pass', + ) assert 'banned' in login_response.get_data(as_text=True) + + @staticmethod + def test_invalid_change_password(captured_templates): + student_user = conftest.create_student_user(index=1) + client = conftest.get_logged_user(student_user.username) + for _ in range(MAX_INVALID_PASSWORD_TRIES): + conftest.change_client_password( + client, 'wrong pass', 'some_password', 'some_password', + ) + template, _ = captured_templates[-1] + assert template.name == "change-password.html" + + conftest.change_client_password( + client, 'fake pass', 'some_password', 'some_password', + ) + template, _ = captured_templates[-1] + assert template.name == "change-password.html" + + @staticmethod + def test_valid_change_password(captured_templates): + student_user = conftest.create_student_user(index=1) + client = conftest.get_logged_user(student_user.username) + conftest.change_client_password( + client, 'fake pass', 'some_password', 'some_password', + ) + template, _ = captured_templates[-1] + assert template.name == "login.html" + check_logout_response = client.get('/exercises') + assert check_logout_response.status_code == 302 + + @staticmethod + def test_forgot_my_password_invalid_mail( + client: FlaskClient, captured_templates, + ): + conftest.reset_client_password(client, 'fake-mail@mail.com') + template, _ = captured_templates[-1] + assert template.name == "reset-password.html" + + @staticmethod + def test_forgot_my_password_invalid_recover( + client: FlaskClient, captured_templates, + ): + user = conftest.create_student_user(index=1) + conftest.reset_client_password(client, user.mail_address) + template, _ = captured_templates[-1] + assert template.name == "login.html" + + token = generate_user_token(user) + unknown_id_recover_response = conftest.recover_client_password( + client, user.id + 1, token, 'different pass', 'different pass', + ) + assert unknown_id_recover_response.status_code == 404 + + conftest.recover_client_password( + client, user.id, token, 'wrong pass', 'different pass', + ) + template, _ = captured_templates[-1] + assert template.name == "recover-password.html" + + @staticmethod + def test_forgot_my_password(client: FlaskClient, captured_templates): + user = conftest.create_student_user(index=1) + conftest.reset_client_password(client, user.mail_address) + template, _ = captured_templates[-1] + assert template.name == "login.html" + + token = generate_user_token(user) + conftest.recover_client_password( + client, user.id, token, 'new pass', 'new pass', + ) + template, _ = captured_templates[-1] + assert template.name == "login.html" + + second_try_response = conftest.recover_client_password( + client, user.id, token, 'new pass1', 'new pass1', + ) + assert second_try_response.status_code == 404 + + conftest.login_client_user(client, user.username, 'fake pass') + template, _ = captured_templates[-1] + assert template.name == 'login.html' + + conftest.login_client_user(client, user.username, 'new pass') + template, _ = captured_templates[-1] + assert template.name == 'exercises.html' + + @staticmethod + def test_expired_token(client: FlaskClient): + user = conftest.create_student_user(index=1) + conftest.reset_client_password(client, user.mail_address) + token = generate_user_token(user) + + fake_time = time.time() + CONFIRMATION_TIME + 1 + with patch('time.time', Mock(return_value=fake_time)): + conftest.recover_client_password( + client, user.id, token, 'new pass1', 'new pass1', + ) + conftest.login_client_user(client, user.username, 'new pass1') + fail_login_response = client.get('/exercises') + assert fail_login_response.status_code == 302 + + conftest.login_client_user(client, user.username, 'fake pass') + fail_login_response = client.get('/exercises') + assert fail_login_response.status_code == 200