diff --git a/lms/lmsdb/models.py b/lms/lmsdb/models.py index 8fd5b812..8277e1a5 100644 --- a/lms/lmsdb/models.py +++ b/lms/lmsdb/models.py @@ -34,6 +34,7 @@ class RoleOptions(enum.Enum): BANNED = 'Banned' + UNVERIFIED = 'Unverified' STUDENT = 'Student' STAFF = 'Staff' VIEWER = 'Viewer' @@ -67,6 +68,7 @@ class Role(BaseModel): (RoleOptions.STAFF.value, RoleOptions.STAFF.value), (RoleOptions.VIEWER.value, RoleOptions.VIEWER.value), (RoleOptions.STUDENT.value, RoleOptions.STUDENT.value), + (RoleOptions.UNVERIFIED.value, RoleOptions.UNVERIFIED.value), (RoleOptions.BANNED.value, RoleOptions.BANNED.value), )) @@ -77,6 +79,10 @@ def __str__(self): def get_banned_role(cls) -> 'Role': return cls.get(Role.name == RoleOptions.BANNED.value) + @classmethod + def get_unverified_role(cls) -> 'Role': + return cls.get(Role.name == RoleOptions.UNVERIFIED.value) + @classmethod def get_student_role(cls) -> 'Role': return cls.get(Role.name == RoleOptions.STUDENT.value) @@ -100,6 +106,10 @@ def by_name(cls, name) -> 'Role': def is_banned(self) -> bool: return self.name == RoleOptions.BANNED.value + @property + def is_unverified(self) -> bool: + return self.name == RoleOptions.UNVERIFIED.value + @property def is_student(self) -> bool: return self.name == RoleOptions.STUDENT.value @@ -407,8 +417,7 @@ def set_state(self, new_state: SolutionState, **kwargs) -> bool: **{Solution.state.name: new_state.name}, **kwargs, ).where(requested_solution) - updated = changes.execute() == 1 - return updated + return changes.execute() == 1 def ordered_versions(self) -> Iterable['Solution']: return Solution.select().where( diff --git a/lms/lmsweb/__init__.py b/lms/lmsweb/__init__.py index 5f3a5347..ec48f2ae 100644 --- a/lms/lmsweb/__init__.py +++ b/lms/lmsweb/__init__.py @@ -5,6 +5,7 @@ from flask_babel import Babel # type: ignore from flask_limiter import Limiter # type: ignore from flask_limiter.util import get_remote_address # type: ignore +from flask_mail import Mail # type: ignore from flask_wtf.csrf import CSRFProtect # type: ignore from lms.utils import config_migrator, debug @@ -41,6 +42,8 @@ # Localizing configurations babel = Babel(webapp) +webmail = Mail(webapp) + # Must import files after app's creation from lms.lmsdb import models # NOQA: F401, E402, I202 diff --git a/lms/lmsweb/config.py.example b/lms/lmsweb/config.py.example index 1fab4072..d1c500f5 100644 --- a/lms/lmsweb/config.py.example +++ b/lms/lmsweb/config.py.example @@ -8,6 +8,20 @@ SECRET_KEY = '' MAILGUN_API_KEY = os.getenv('MAILGUN_API_KEY') MAILGUN_DOMAIN = os.getenv('MAILGUN_DOMAIN', 'mail.pythonic.guru') SERVER_ADDRESS = os.getenv('SERVER_ADDRESS', '127.0.0.1:5000') +SITE_NAME = 'Learning Python' + +# REGISTRATION CONFIGURATIONS +REGISTRATION_OPEN = True +CONFIRMATION_TIME = 3600 + +# MAIL CONFIGURATION +MAIL_SERVER = 'smtp.gmail.com' +MAIL_PORT = 465 +MAIL_USE_SSL = True +MAIL_USE_TLS = False +MAIL_USERNAME = 'username@gmail.com' +MAIL_PASSWORD = 'password' +MAIL_DEFAULT_SENDER = 'username@gmail.com' # ADMIN PANEL FLASK_ADMIN_FLUID_LAYOUT = True diff --git a/lms/lmsweb/forms/register.py b/lms/lmsweb/forms/register.py new file mode 100644 index 00000000..5e7eb526 --- /dev/null +++ b/lms/lmsweb/forms/register.py @@ -0,0 +1,34 @@ +from flask_babel import gettext as _ # type: ignore +from flask_wtf import FlaskForm +from wtforms import PasswordField, StringField +from wtforms.validators import Email, EqualTo, InputRequired, Length + +from lms.lmsweb.tools.validators import ( + UniqueEmailRequired, UniqueUsernameRequired, +) + + +class RegisterForm(FlaskForm): + email = StringField( + 'Email', validators=[ + InputRequired(), Email(message=_('אימייל לא תקין')), + UniqueEmailRequired, + ], + ) + username = StringField( + 'Username', validators=[ + InputRequired(), UniqueUsernameRequired, Length(min=4, max=20), + ], + ) + fullname = StringField( + 'Full Name', validators=[InputRequired(), Length(min=3, max=60)], + ) + 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 new file mode 100644 index 00000000..2f502b60 --- /dev/null +++ b/lms/lmsweb/tools/validators.py @@ -0,0 +1,21 @@ +from flask_babel import gettext as _ # type: ignore +from wtforms.fields.core import StringField +from wtforms.validators import ValidationError + +from lms.lmsdb.models import User + + +def UniqueUsernameRequired( + _form: 'RegisterForm', field: StringField, # type: ignore # NOQA: F821 +) -> None: + username_exists = User.get_or_none(User.username == field.data) + if username_exists: + raise ValidationError(_('שם המשתמש כבר נמצא בשימוש')) + + +def UniqueEmailRequired( + _form: 'RegisterForm', field: StringField, # type: ignore # NOQA: F821 +) -> None: + email_exists = User.get_or_none(User.mail_address == field.data) + if 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 5b5f12e3..0c35ce13 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: 2020-10-09 11:20+0300\n" +"POT-Creation-Date: 2021-09-12 15:10+0300\n" "PO-Revision-Date: 2020-09-16 18:29+0300\n" "Last-Translator: Or Ronai\n" "Language: en\n" @@ -16,9 +16,9 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.8.0\n" +"Generated-By: Babel 2.9.1\n" -#: lmsdb/models.py:632 +#: lmsdb/models.py:698 msgid "כישלון חמור" msgstr "Fatal error" @@ -45,26 +45,79 @@ msgstr "The automatic checker couldn't run your code." msgid "אחי, בדקת את הקוד שלך?" msgstr "Bro, did you check your code?" +#: lmsweb/views.py:122 +msgid "לא ניתן להירשם כעת" +msgstr "Can not register now" + +#: lmsweb/views.py:139 +msgid "ההרשמה בוצעה בהצלחה" +msgstr "Registration successfully" + +#: lmsweb/views.py:161 +msgid "קישור האימות פג תוקף, קישור חדש נשלח אל תיבת המייל שלך" +msgstr "The confirmation link is expired, new link has been sent to your email" + +#: lmsweb/views.py:174 +#, fuzzy +msgid "המשתמש שלך אומת בהצלחה, כעת אתה יכול להתחבר למערכת" +msgstr "Your user has been successfully confirmed, you can now login" + +#: lmsweb/forms/register.py:14 +msgid "אימייל לא תקין" +msgstr "Invalid email" + +#: lmsweb/forms/register.py:32 +msgid "הסיסמאות שהוקלדו אינן זהות" +msgstr "The passwords are not identical" + #: lmsweb/tools/registration.py:105 msgid "מערכת הגשת התרגילים" msgstr "Exercuse submission system" -#: models/solutions.py:46 +#: lmsweb/tools/validators.py:13 +msgid "שם המשתמש כבר נמצא בשימוש" +msgstr "The username already in use" + +#: lmsweb/tools/validators.py:21 +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" + +#: models/solutions.py:50 #, python-format msgid "%(solver)s הגיב לך על בדיקת תרגיל \"%(subject)s\"." msgstr "%(solver)s has replied for your \"%(subject)s\" check." -#: models/solutions.py:53 +#: models/solutions.py:57 #, python-format msgid "%(checker)s הגיב לך על תרגיל \"%(subject)s\"." msgstr "%(checker)s replied for \"%(subject)s\"." -#: models/solutions.py:65 +#: models/solutions.py:69 #, python-format msgid "הפתרון שלך לתרגיל \"%(subject)s\" נבדק." msgstr "Your solution for the \"%(subject)s\" exercise has been checked." -#: templates/banned.html:8 templates/login.html:7 +#: models/users.py:25 +#, fuzzy +msgid "שם המשתמש או הסיסמה שהוזנו לא תקינים" +msgstr "Invalid username or password" + +#: models/users.py:27 +#, 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 msgid "תמונת הפרופיל של קורס פייתון" msgstr "Profile picture of the Python Course" @@ -84,7 +137,7 @@ msgstr "Exercise submission system for the Python Course" msgid "תרגילים" msgstr "Exercises" -#: templates/exercises.html:21 +#: templates/exercises.html:21 templates/view.html:101 msgid "הערות על התרגיל" msgstr "Comments for the solution" @@ -108,7 +161,7 @@ msgstr "All Exercises" msgid "התחברות" msgstr "Login" -#: templates/login.html:10 +#: templates/login.html:10 templates/signup.html:11 msgid "ברוכים הבאים למערכת התרגילים!" msgstr "Welcome to the exercise system!" @@ -116,18 +169,22 @@ msgstr "Welcome to the exercise system!" msgid "הזינו את שם המשתמש והסיסמה שלכם:" msgstr "Insert your username and password:" -#: templates/login.html:15 templates/login.html:17 +#: templates/login.html:22 templates/login.html:24 templates/signup.html:16 msgid "שם משתמש" msgstr "Username" -#: templates/login.html:21 templates/login.html:23 +#: templates/login.html:28 templates/login.html:30 templates/signup.html:18 msgid "סיסמה" msgstr "Password" -#: templates/login.html:28 +#: templates/login.html:35 msgid "התחבר" msgstr "Login" +#: templates/login.html:39 templates/signup.html:22 +msgid "הירשם" +msgstr "Register" + #: templates/navbar.html:8 msgid "הלוגו של פרויקט לומדים פייתון: נחש צהוב על רקע עיגול בצבע תכלת, ומתחתיו כתוב - לומדים פייתון." msgstr "The logo of the Learning Python project: yellow snake on light blue circle background and behind of it written - Learning Python" @@ -164,6 +221,32 @@ msgstr "Check Exercises" msgid "התנתקות" msgstr "Logout" +#: templates/signup.html:9 +#, fuzzy +msgid "הרשמה" +msgstr "Registration" + +#: templates/signup.html:12 +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" + +#: templates/signup.html:19 +#, fuzzy +msgid "אימות סיסמה" +msgstr "Password Confirmation" + +#: templates/signup.html:25 +msgid "חזרה לדף ההתחברות" +msgstr "Back to login page" + #: templates/status.html:7 msgid "חמ\"ל תרגילים" msgstr "Exercises operations room" @@ -256,23 +339,23 @@ msgstr "Submitted" msgid "לא הוגש" msgstr "Not submitted" -#: templates/user.html:43 +#: templates/user.html:44 msgid "פתקיות:" msgstr "Notes:" -#: templates/user.html:60 templates/user.html:62 +#: templates/user.html:49 templates/user.html:51 msgid "פתקית חדשה" msgstr "New Note" -#: templates/user.html:66 +#: templates/user.html:55 msgid "תרגיל משויך:" msgstr "Exercise:" -#: templates/user.html:75 +#: templates/user.html:64 msgid "רמת פרטיות:" msgstr "Privacy Level:" -#: templates/user.html:81 +#: templates/user.html:70 msgid "הוסף פתקית" msgstr "Add Note" @@ -332,10 +415,6 @@ msgstr "Error:" msgid "שגיאת סגל:" msgstr "Staff Error:" -#: templates/view.html:101 -msgid "הערות על התרגיל" -msgstr "Comments for the exercise" - #: templates/view.html:109 msgid "הערות כלליות" msgstr "General comments" diff --git a/lms/lmsweb/views.py b/lms/lmsweb/views.py index ad1d188e..eecc397d 100644 --- a/lms/lmsweb/views.py +++ b/lms/lmsweb/views.py @@ -5,15 +5,17 @@ jsonify, make_response, render_template, request, send_from_directory, url_for, ) +from flask_babel import gettext as _ # type: ignore from flask_limiter.util import get_remote_address # type: ignore from flask_login import ( # type: ignore current_user, login_required, login_user, logout_user, ) +from itsdangerous import BadSignature, SignatureExpired from werkzeug.datastructures import FileStorage from werkzeug.utils import redirect from lms.lmsdb.models import ( - ALL_MODELS, Comment, Note, RoleOptions, SharedSolution, + ALL_MODELS, Comment, Note, Role, RoleOptions, SharedSolution, Solution, SolutionFile, User, database, ) from lms.lmsweb import babel, limiter, routes, webapp @@ -21,8 +23,10 @@ AdminModelView, SPECIAL_MAPPING, admin, managers_only, ) from lms.lmsweb.config import ( - LANGUAGES, LIMITS_PER_HOUR, LIMITS_PER_MINUTE, LOCALE, MAX_UPLOAD_SIZE, + CONFIRMATION_TIME, LANGUAGES, LIMITS_PER_HOUR, + LIMITS_PER_MINUTE, LOCALE, MAX_UPLOAD_SIZE, ) +from lms.lmsweb.forms.register import RegisterForm from lms.lmsweb.manifest import MANIFEST from lms.lmsweb.redirections import ( PERMISSIVE_CORS, get_next_url, login_manager, @@ -30,7 +34,12 @@ from lms.models import ( comments, notes, notifications, share_link, solutions, upload, ) -from lms.models.errors import FileSizeError, LmsError, UploadError, fail +from lms.models.errors import ( + 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.utils.consts import RTL_LANGUAGES from lms.utils.files import ( get_language_name_by_extension, get_mime_type_by_extention, @@ -83,27 +92,90 @@ def ratelimit_handler(e): f'{LIMITS_PER_MINUTE}/minute;{LIMITS_PER_HOUR}/hour', deduct_when=lambda response: response.status_code != 200, ) -def login(login_error: Optional[str] = None): - +def login(login_message: Optional[str] = None): if current_user.is_authenticated: return get_next_url(request.args.get('next')) username = request.form.get('username') password = request.form.get('password') next_page = request.form.get('next') - login_error = request.args.get('login_error') - user = User.get_or_none(username=username) + login_message = request.args.get('login_message') if request.method == 'POST': - if user is not None and user.is_password_valid(password): + try: + user = auth(username, password) + except (ForbiddenPermission, UnauthorizedError) as e: + error_message, _ = e.args + error_details = {'next': next_page, 'login_message': error_message} + return redirect(url_for('login', **error_details)) + else: login_user(user) return get_next_url(next_page) - elif user is None or not user.is_password_valid(password): - login_error = 'Invalid username or password' - error_details = {'next': next_page, 'login_error': login_error} - return redirect(url_for('login', **error_details)) - return render_template('login.html', login_error=login_error) + return render_template('login.html', login_message=login_message) + + +@webapp.route('/signup', methods=['GET', 'POST']) +@limiter.limit(f'{LIMITS_PER_MINUTE}/minute;{LIMITS_PER_HOUR}/hour') +def signup(): + if not webapp.config.get('REGISTRATION_OPEN', False): + return redirect(url_for( + 'login', login_message=_('לא ניתן להירשם כעת'), + )) + + form = RegisterForm() + if not form.validate_on_submit(): + return render_template('signup.html', form=form) + + user = User.create(**{ + User.mail_address.name: form.email.data, + User.username.name: form.username.data, + User.fullname.name: form.fullname.data, + User.role.name: Role.get_unverified_role(), + User.password.name: form.password.data, + User.api_key.name: User.random_password(), + }) + send_confirmation_mail(user) + return redirect(url_for( + 'login', login_message=_('ההרשמה בוצעה בהצלחה'), + )) + + +@webapp.route('/confirm-email//') +@limiter.limit(f'{LIMITS_PER_MINUTE}/minute;{LIMITS_PER_HOUR}/hour') +def confirm_email(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.') + + if not user.role.is_unverified: + return fail(403, 'User has been already confirmed.') + + try: + SERIALIZER.loads( + token, salt=retrieve_salt(user), max_age=CONFIRMATION_TIME, + ) + + except SignatureExpired: + send_confirmation_mail(user) + return redirect(url_for( + 'login', login_message=( + _('קישור האימות פג תוקף, קישור חדש נשלח אל תיבת המייל שלך'), + ), + )) + except BadSignature: + return fail(404, 'The authentication code is invalid.') + + else: + update = User.update( + role=Role.get_student_role(), + ).where(User.username == user.username) + update.execute() + return redirect(url_for( + 'login', login_message=( + _('המשתמש שלך אומת בהצלחה, כעת אתה יכול להתחבר למערכת'), + ), + )) @webapp.route('/logout') diff --git a/lms/models/errors.py b/lms/models/errors.py index 35fecd17..1a2ec15d 100644 --- a/lms/models/errors.py +++ b/lms/models/errors.py @@ -5,10 +5,6 @@ class LmsError(Exception): pass -class UploadError(LmsError): - pass - - class AlreadyExists(LmsError): pass @@ -21,10 +17,22 @@ class FileSizeError(LmsError): pass +class UnhashedPasswordError(LmsError): + pass + + +class UploadError(LmsError): + pass + + class NotValidRequest(LmsError): # Error 400 pass +class UnauthorizedError(LmsError): # Error 401 + pass + + class ForbiddenPermission(LmsError): # Error 403 pass diff --git a/lms/models/register.py b/lms/models/register.py new file mode 100644 index 00000000..f19937cb --- /dev/null +++ b/lms/models/register.py @@ -0,0 +1,30 @@ +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 new file mode 100644 index 00000000..bb2720a6 --- /dev/null +++ b/lms/models/users.py @@ -0,0 +1,28 @@ +import re + +from flask_babel import gettext as _ # type: ignore + +from lms.lmsdb.models import User +from lms.models.errors import ( + ForbiddenPermission, UnauthorizedError, UnhashedPasswordError, +) + + +HASHED_PASSWORD = re.compile(r'^pbkdf2.+?\$(?P.+?)\$(?P.+)') + + +def retrieve_salt(user: User) -> str: + password = HASHED_PASSWORD.match(user.password) + try: + return password.groupdict().get('salt') + except AttributeError: # should never happen + raise UnhashedPasswordError('Password format is invalid.') + + +def auth(username: str, password: str) -> User: + user = User.get_or_none(username=username) + if user is None or not user.is_password_valid(password): + raise UnauthorizedError(_('שם המשתמש או הסיסמה שהוזנו לא תקינים'), 400) + elif user.role.is_unverified: + raise ForbiddenPermission(_('עליך לאשר את מייל האימות'), 403) + return user diff --git a/lms/static/my.css b/lms/static/my.css index 8380ae18..4e9cd078 100644 --- a/lms/static/my.css +++ b/lms/static/my.css @@ -46,25 +46,29 @@ a { text-align: right; } -#login-container { +#login-container, +#signup-container { height: 100%; align-items: center; display: flex; align-self: center; } -#login { +#login, +#signup { margin: auto; max-width: 420px; padding: 15px; width: 100%; } -#login-logo { +#login-logo, +#signup-logo { margin-bottom: 1rem; } -#login-messege-box { +#login-message-box, +#signup-errors { background: #f1c8c8; color: #860606; } diff --git a/lms/templates/_formhelpers.html b/lms/templates/_formhelpers.html new file mode 100644 index 00000000..4175e804 --- /dev/null +++ b/lms/templates/_formhelpers.html @@ -0,0 +1,13 @@ +{% macro render_field(field, cls) %} +
+ + {{ field(class=cls, **kwargs) | safe }} +
+ {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} +
+
+{% endmacro %} diff --git a/lms/templates/login.html b/lms/templates/login.html index 3fb5515e..b57f9238 100644 --- a/lms/templates/login.html +++ b/lms/templates/login.html @@ -10,10 +10,10 @@

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

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

- {% if login_error %} -
+ {% if login_message %} +

- {{ login_error }} + {{ login_message }}

{% endif %} @@ -34,6 +34,10 @@

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

+ {% if config.REGISTRATION_OPEN %} +
+ {{ _('הירשם') }} + {% endif %}
diff --git a/lms/templates/signup.html b/lms/templates/signup.html new file mode 100644 index 00000000..b888068b --- /dev/null +++ b/lms/templates/signup.html @@ -0,0 +1,29 @@ +{% extends 'base.html' %} +{% from "_formhelpers.html" import render_field %} + +{% block page_content %} +
+
+
+ +

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

+

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

+
+ {{ 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=_('שם מלא')) }} + {{ 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/requirements.txt b/requirements.txt index 9b9990eb..3b6bda24 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,6 +19,7 @@ debugpy==1.0.0rc2 decorator==4.4.2 diff-cover==2.6.1 docker-pycreds==0.4.0 +email-validator==1.1.3 entrypoints==0.3 eradicate==1.0 flake8-alfred==1.1.1 @@ -41,7 +42,8 @@ flake8==3.8.3 Flask-Admin==1.5.8 Flask-Babel==2.0.0 Flask-Limiter==1.4 -git+git://github.com/maxcountryman/flask-login@e3d8079#egg=flask-login +Flask-Login==0.5.0 +Flask-Mail==0.9.1 Flask-WTF==0.14.3 Flask==1.1.2 future==0.18.2 @@ -55,7 +57,7 @@ ipykernel==5.3.4 ipython-genutils==0.2.0 ipython==7.18.1 ipywidgets==7.5.1 -itsdangerous==1.1.0 +itsdangerous==2.0.1 jedi==0.17.2 jinja2-pluralize==0.3.0 Jinja2==2.11.3 diff --git a/tests/conftest.py b/tests/conftest.py index 226209dc..93caf4b9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,11 @@ import datetime from functools import wraps -from lms.models import notes import os import random import string from typing import List, Optional +from flask import template_rendered from flask.testing import FlaskClient from peewee import SqliteDatabase import pytest @@ -49,6 +49,11 @@ def db(db_in_memory): db_in_memory.rollback() +@pytest.fixture(autouse=True, scope='function') +def client(): + return webapp.test_client() + + @pytest.fixture(autouse=True, scope='session') def celery_eager(): public_app.conf.update(task_always_eager=True) @@ -65,6 +70,16 @@ def webapp_configurations(): limiter.enabled = False +@pytest.fixture(autouse=True, scope='session') +def disable_mail_sending(): + webapp.config['TESTING'] = True + + +@pytest.fixture(autouse=True, scope='session') +def enable_registration(): + webapp.config['REGISTRATION_OPEN'] = True + + def disable_shareable_solutions(): webapp.config['SHAREABLE_SOLUTIONS'] = False @@ -77,6 +92,10 @@ def enable_users_comments(): webapp.config['USERS_COMMENTS'] = True +def disable_registration(): + webapp.config['REGISTRATION_OPEN'] = False + + def use_limiter(func): @wraps(func) def wrapper(*args, **kwargs): @@ -118,6 +137,10 @@ def create_banned_user(index: int = 0) -> User: return create_user(RoleOptions.BANNED.value, index) +def create_unverified_user(index: int = 0) -> User: + return create_user(RoleOptions.UNVERIFIED.value, index) + + def create_student_user(index: int = 0) -> User: return create_user(RoleOptions.STUDENT.value, index) @@ -141,6 +164,11 @@ def staff_user(staff_password): return create_staff_user() +@pytest.fixture() +def unverified_user(): + return create_unverified_user() + + @pytest.fixture() def student_user(): return create_student_user() @@ -159,6 +187,20 @@ def admin_user(): ) +@pytest.fixture(autouse=True, scope='function') +def captured_templates(): + recorded = [] + + def record(sender, template, context, **kwargs): + recorded.append((template, context)) + + template_rendered.connect(record, webapp) + try: + yield recorded + finally: + template_rendered.disconnect(record, webapp) + + def create_notification( student_user: User, solution: Solution, diff --git a/tests/samples/config_copy.py b/tests/samples/config_copy.py index 0d2d93ce..d56c7b9d 100644 --- a/tests/samples/config_copy.py +++ b/tests/samples/config_copy.py @@ -12,6 +12,7 @@ ) + USERS_CSV = 'users.csv' diff --git a/tests/test_flask_limiter.py b/tests/test_flask_limiter.py index 311cff55..3457230d 100644 --- a/tests/test_flask_limiter.py +++ b/tests/test_flask_limiter.py @@ -1,3 +1,5 @@ +from flask.testing import FlaskClient + from lms.lmsweb import routes from lms.lmsdb.models import Solution, User from lms.lmsweb import webapp @@ -7,8 +9,7 @@ class TestLimiter: @staticmethod @conftest.use_limiter - def test_limiter_login_fails(student_user: User): - client = webapp.test_client() + def test_limiter_login_fails(client: FlaskClient, student_user: User): for _ in range(webapp.config['LIMITS_PER_MINUTE'] - 1): response = client.post('/login', data={ 'username': student_user.username, @@ -25,16 +26,14 @@ def test_limiter_login_fails(student_user: User): @staticmethod @conftest.use_limiter - def test_limiter_login_refreshes(): - client = webapp.test_client() + def test_limiter_login_refreshes(client: FlaskClient): for _ in range(webapp.config['LIMITS_PER_MINUTE'] + 1): response = client.get('/login') assert response.status_code == 200 @staticmethod @conftest.use_limiter - def test_limiter_login_success(student_user: User): - client = webapp.test_client() + def test_limiter_login_success(client: FlaskClient, student_user: User): client.post('/login', data={ 'username': student_user.username, 'password': 'fake5', diff --git a/tests/test_login.py b/tests/test_login.py index f44edc45..5a6f8e91 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -1,11 +1,11 @@ +from flask.testing import FlaskClient + from lms.lmsdb.models import User -from lms.lmsweb import webapp class TestLogin: @staticmethod - def test_login_password_fail(student_user: User): - client = webapp.test_client() + def test_login_password_fail(client: FlaskClient, student_user: User): client.post('/login', data={ 'username': student_user.username, 'password': 'wrong_pass', @@ -14,8 +14,7 @@ def test_login_password_fail(student_user: User): assert fail_login_response.status_code == 302 @staticmethod - def test_login_username_fail(student_user: User): - client = webapp.test_client() + def test_login_username_fail(client: FlaskClient): client.post('/login', data={ 'username': 'wrong_user', 'password': 'fake pass', @@ -24,8 +23,21 @@ def test_login_username_fail(student_user: User): assert fail_login_response.status_code == 302 @staticmethod - def test_login_success(student_user: User): - client = webapp.test_client() + 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) + template, _ = captured_templates[-1] + assert template.name == 'login.html' + + fail_login_response = client.get('/exercises') + assert fail_login_response.status_code == 302 + + @staticmethod + def test_login_success(client: FlaskClient, student_user: User): client.post('/login', data={ 'username': student_user.username, 'password': 'fake pass', diff --git a/tests/test_registration.py b/tests/test_registration.py new file mode 100644 index 00000000..f09b4d49 --- /dev/null +++ b/tests/test_registration.py @@ -0,0 +1,178 @@ +import time +from unittest.mock import Mock, patch + +from flask.testing import FlaskClient + +from lms.lmsweb.config import CONFIRMATION_TIME +from lms.lmsdb.models import User +from lms.models.register import generate_confirmation_token +from tests import conftest + + +class TestRegistration: + @staticmethod + 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) + template, _ = captured_templates[-1] + assert template.name == "signup.html" + + client.post('/login', data={ + 'username': student_user.username, + 'password': 'some_password', + }, follow_redirects=True) + fail_login_response = client.get('/exercises') + assert fail_login_response.status_code == 302 + + @staticmethod + def test_invalid_email( + client: FlaskClient, student_user: User, captured_templates, + ): + client.post('/signup', data={ + 'email': student_user.mail_address, + 'username': 'some_user', + 'fullname': 'some_name', + 'password': 'some_password', + 'confirm': 'some_password', + }, follow_redirects=True) + 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) + 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) + user = User.get_or_none(User.username == 'some_user') + bad_token = "fake-token43@$@" + fail_confirm_response = client.get( + f'/confirm-email/{user.id}/{bad_token}', follow_redirects=True, + ) + assert fail_confirm_response.status_code == 404 + + # No such 999 user id + another_fail_response = client.get( + f'/confirm-email/999/{bad_token}', follow_redirects=True, + ) + assert another_fail_response.status_code == 404 + + @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) + user = User.get_or_none(User.username == 'some_user') + token = generate_confirmation_token(user) + success_token_response = client.get( + f'/confirm-email/{user.id}/{token}', follow_redirects=True, + ) + assert success_token_response.status_code == 200 + + fail_token_response = client.get( + f'/confirm-email/{user.id}/{token}', follow_redirects=True, + ) + assert fail_token_response.status_code == 403 + + @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) + user = User.get_or_none(User.username == 'some_user') + token = generate_confirmation_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) + fail_login_response = client.get('/exercises') + assert fail_login_response.status_code == 302 + + 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_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) + user = User.get_or_none(User.username == 'some_user') + assert user is None + + response = client.get('/signup') + template, _ = captured_templates[-1] + assert template.name == 'login.html' + assert '/signup' not in response.get_data(as_text=True) diff --git a/tests/test_users.py b/tests/test_users.py index 8adb55b4..043570ca 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -1,5 +1,6 @@ +from flask.testing import FlaskClient + from lms.lmsdb.models import User -from lms.lmsweb import webapp from tests import conftest @@ -61,8 +62,7 @@ def test_logout(student_user: User): assert logout_response.status_code == 200 @staticmethod - def test_banned_user(banned_user: User): - client = client = webapp.test_client() + def test_banned_user(client: FlaskClient, banned_user: User): login_response = client.post('/login', data={ 'username': banned_user.username, 'password': 'fake pass',