diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index a5419e1..c65f0fc 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -8,10 +8,12 @@ La liste de paquets fourni est pour Archlinux, les paquets peuvent avoir des nom python3 python-flask python-flask-login +python-flask-mail python-flask-migrate python-flask-script python-flask-sqlalchemy python-flask-wtf +python-itsdangerous python-ldap python-uwsgi python-psycopg2 diff --git a/app/__init__.py b/app/__init__.py index c5580f4..8a53d17 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -2,6 +2,7 @@ from flask import Flask, g from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate from flask_login import LoginManager +from flask_mail import Mail from config import Config import time @@ -14,6 +15,7 @@ if Config.SECRET_KEY == "a-random-secret-key": db = SQLAlchemy(app) migrate = Migrate(app, db) +mail = Mail(app) login = LoginManager(app) login.login_view = 'login' diff --git a/app/data/trophies.yaml b/app/data/trophies.yaml index d56a62d..c6b6857 100644 --- a/app/data/trophies.yaml +++ b/app/data/trophies.yaml @@ -7,7 +7,7 @@ - name: Membre de CreativeCalc is_title: True - description: Membre de l'association qui gère Planète Casio ! + description: Membre de l'association qui gère Planète Casio ! hidden: True - name: Membre d'honneur @@ -49,7 +49,7 @@ - name: Romancier émérite is_title: True - description: Écrire 5000 messages ! + description: Écrire 5000 messages ! hidden: False # Number of posted tutorials @@ -66,7 +66,7 @@ - name: Guerrier du savoir is_title: True - description: Élaborer 25 tutoriels ! + description: Élaborer 25 tutoriels ! hidden: False # Account age (awarded on login only) @@ -93,7 +93,7 @@ - name: Vétéran mythique is_title: True - description: Être membre pendant 10 ans ! + description: Être membre pendant 10 ans ! hidden: False # Number of "good" programs @@ -110,7 +110,7 @@ - name: Je code donc je suis is_title: True - description: Publier 20 programmes ! + description: Publier 20 programmes ! hidden: False # Number of posted tests @@ -127,7 +127,7 @@ - name: Hard tester is_title: True - description: Partager 100 tests ! + description: Partager 100 tests ! hidden: False # Number of event participations @@ -144,7 +144,7 @@ - name: Concurrent de l'extrême is_title: True - description: Participer à 15 événements du site ! + description: Participer à 15 événements du site ! hidden: False # Number of posted art @@ -161,7 +161,7 @@ - name: Roi du pixel is_title: True - description: Publier 100 assets graphiques ! + description: Publier 100 assets graphiques ! hidden: False # Miscellaneous automatically awarded @@ -178,10 +178,10 @@ - name: Maître du code is_title: True - description: Être décoré 5 fois du label de qualité ! + description: Être décoré 5 fois du label de qualité ! hidden: False - name: Bourreau des cœurs is_title: True - description: Foudroyer les cœurs à 5 reprises ! + description: Foudroyer les cœurs à 5 reprises ! hidden: False diff --git a/app/forms/account.py b/app/forms/account.py index 20473d6..660a7e3 100644 --- a/app/forms/account.py +++ b/app/forms/account.py @@ -137,6 +137,35 @@ class DeleteAccountForm(FlaskForm): ) +class AskResetPasswordForm(FlaskForm): + email = EmailField( + 'Adresse email', + validators=[ + Optional(), + Email(message="Addresse email invalide."), + ], + ) + submit = SubmitField('Valider') + + +class ResetPasswordForm(FlaskForm): + password = PasswordField( + 'Mot de passe', + validators=[ + Optional(), + vd.password, + ], + ) + password2 = PasswordField( + 'Répéter le mot de passe', + validators=[ + Optional(), + EqualTo('password', message="Les mots de passe doivent être identiques."), + ], + ) + submit = SubmitField('Valider') + + class AdminUpdateAccountForm(FlaskForm): username = StringField( 'Pseudonyme', diff --git a/app/models/users.py b/app/models/users.py index 1b7f4c9..1fc14ad 100644 --- a/app/models/users.py +++ b/app/models/users.py @@ -76,6 +76,7 @@ class Member(User): norm = db.Column(db.Unicode(V5Config.USER_NAME_MAXLEN), index=True, unique=True) email = db.Column(db.Unicode(120), index=True, unique=True) + email_confirmed = db.Column(db.Boolean) password_hash = db.Column(db.String(255)) xp = db.Column(db.Integer) register_date = db.Column(db.Date, default=date.today) @@ -118,6 +119,7 @@ class Member(User): self.name = name self.norm = unicode_names.normalize(name) self.email = email + self.email_confirmed = not V5Config.ENABLE_EMAIL_CONFIRMATION if not V5Config.USE_LDAP: self.set_password(password) # Workflow with LDAP enabled is User → Postgresql → LDAP → set password diff --git a/app/routes/account/account.py b/app/routes/account/account.py index 6b085ab..015a422 100644 --- a/app/routes/account/account.py +++ b/app/routes/account/account.py @@ -1,10 +1,14 @@ -from flask import redirect, url_for, request, flash +from flask import redirect, url_for, request, flash, abort from flask_login import login_required, current_user, logout_user from app import app, db -from app.forms.account import UpdateAccountForm, RegistrationForm, DeleteAccountForm +from app.forms.account import UpdateAccountForm, RegistrationForm, \ + DeleteAccountForm, AskResetPasswordForm, ResetPasswordForm from app.models.users import Member from app.utils.render import render +from app.utils.send_mail import send_validation_mail, send_reset_password_mail +from app.utils.priv_required import guest_only import app.utils.ldap as ldap +from itsdangerous import URLSafeTimedSerializer from config import V5Config @@ -33,6 +37,45 @@ def edit_account(): return render('account/account.html', form=form) +@app.route('/compte/reinitialiser', methods=['GET', 'POST']) +@guest_only +def ask_reset_password(): + form = AskResetPasswordForm() + if form.submit.data: + m = Member.query.filter_by(email=form.email.data).first() + if m is not None: + send_reset_password_mail(m.name, m.email) + flash('Un email a été envoyé à l\'adresse renseignée', 'ok') + return redirect(url_for('login')) + elif request.method == "POST": + flash('Une erreur est survenue', 'error') + + return render('account/ask_reset_password.html', form=form) + +@app.route('/compte/reinitialiser/', methods=['GET', 'POST']) +@guest_only +def reset_password(token): + try: + ts = URLSafeTimedSerializer(app.config["SECRET_KEY"]) + email = ts.loads(token, salt="email-confirm-key", max_age=86400) + except Exception as e: + print(f"Error: {e}") + abort(404) + + form = ResetPasswordForm() + if form.submit.data: + if form.validate_on_submit(): + m = Member.query.filter_by(email=email).first_or_404() + m.set_password(form.password.data) + db.session.merge(m) + db.session.commit() + flash('Modifications effectuées', 'ok') + return redirect(url_for('login')) + else: + flash('Erreur lors de la modification', 'error') + + return render('account/reset_password.html', form=form) + @app.route('/compte/supprimer', methods=['GET', 'POST']) @login_required @@ -52,9 +95,8 @@ def delete_account(): @app.route('/inscription', methods=['GET', 'POST']) +@guest_only def register(): - if current_user.is_authenticated: - return redirect(url_for('index')) form = RegistrationForm() if form.validate_on_submit(): member = Member(form.username.data, form.email.data, form.password.data) @@ -66,13 +108,42 @@ def register(): ldap.add_member(member) ldap.set_password(member, form.password.data) flash('Inscription réussie', 'ok') + + # Email validation message + send_validation_mail(member.name, member.email) + return redirect(url_for('validation') + "?email=" + form.email.data) return render('account/register.html', title='Register', form=form) -@app.route('/register/validation/', methods=['GET', 'POST']) +@app.route('/inscription/validation', methods=['GET']) +@guest_only def validation(): - mail = request.args['email'] + try: + mail = request.args['email'] + except Exception as e: + print("Error: {e}") + abort(404) + if current_user.is_authenticated: return redirect(url_for('index')) return render('account/validation.html', mail=mail) + +@app.route('/inscription/validation/', methods=['GET']) +@guest_only +def activate_account(token): + try: + ts = URLSafeTimedSerializer(app.config["SECRET_KEY"]) + email = ts.loads(token, salt="email-confirm-key", max_age=86400) + except Exception as e: + print(f"Error: {e}") + abort(404) + + m = Member.query.filter_by(email=email).first_or_404() + m.email_confirmed = True + + db.session.add(m) + db.session.commit() + + flash("L'email a bien été confirmé", "ok") + return redirect(url_for('login')) diff --git a/app/routes/account/login.py b/app/routes/account/login.py index 5f952c3..37d1f03 100644 --- a/app/routes/account/login.py +++ b/app/routes/account/login.py @@ -6,11 +6,19 @@ from app.forms.login import LoginForm from app.models.users import Member from app.models.privs import Group from app.utils.render import render +from app.utils.send_mail import send_validation_mail from config import V5Config @app.route('/connexion', methods=['GET', 'POST']) def login(): + # If something failed, return abort("Message") + def _abort(msg): + flash(msg, 'error') + if request.referrer: + return redirect(request.referrer) + return redirect(url_for('index')) + if current_user.is_authenticated: return redirect(url_for('index')) @@ -24,17 +32,17 @@ def login(): # Check if member can login if member is not None and "No login" in [g.name for g in member.groups]: - flash('Cet utilisateur ne peut pas se connecter', 'error') - if request.referrer: - return redirect(request.referrer) - return redirect(url_for('index')) + return _abort('Cet utilisateur ne peut pas se connecter') # Check if password is ok if member is None or not member.check_password(form.password.data): - flash('Pseudo ou mot de passe invalide', 'error') - if request.referrer: - return redirect(request.referrer) - return redirect(url_for('index')) + return _abort('Pseudo ou mot de passe invalide') + + # Check if user is activated + if member.email_confirmed == False: + # Send another mail + send_validation_mail(member.name, member.email) + return _abort(f"Email non confirmé. Un mail de confirmation a de nouveau été envoyé à l'adresse {member.email}") # Login & update time-based trophies login_user(member, remember=form.remember_me.data, diff --git a/app/static/css/table.css b/app/static/css/table.css index 0375b2c..e104f78 100644 --- a/app/static/css/table.css +++ b/app/static/css/table.css @@ -68,18 +68,9 @@ table.topiclist tr > td:last-child { table.thread { width: 100%; } -table.thread td.member { +table.thread td.author { width: 20%; } -table.thread td.guest { - width: 20%; padding-top: 12px; - text-align: center; -} -table.thread td.guest em { - display: block; - font-weight: bold; font-style: normal; - margin-bottom: 8px; -} table.thread td { vertical-align: top; } diff --git a/app/static/css/widgets.css b/app/static/css/widgets.css index 8602857..4b4ee68 100644 --- a/app/static/css/widgets.css +++ b/app/static/css/widgets.css @@ -32,26 +32,36 @@ } .profile-xp div { height: 10px; - background: var(--background-xp); - border: var(--border-xp); - margin: -1px; + background: var(--background-xp); + border: var(--border-xp); + margin: -1px; } +.profile.guest { + flex-direction: column; + width: 100%; padding-top: 12px; + text-align: center; +} +.profile.guest em { + display: block; + font-weight: bold; font-style: normal; + margin-bottom: 8px; +} /* Trophies */ .trophies { - display: flex; flex-wrap: wrap; justify-content: space-between; + display: flex; flex-wrap: wrap; justify-content: space-between; } .trophy { - display: flex; align-items: center; - width: 260px; - margin: 5px; padding: 5px; + display: flex; align-items: center; + width: 260px; + margin: 5px; padding: 5px; border: 1px solid #c5c5c5; border-left: 5px solid var(--links); - border-radius: 2px; + border-radius: 2px; } .trophy img { - height: 50px; margin-right: 5px; + height: 50px; margin-right: 5px; } .trophy div > * { display: block; @@ -65,7 +75,7 @@ } .trophy.disabled { - filter: grayscale(100%); + filter: grayscale(100%); opacity: .5; border-left: 1px solid #c5c5c5; } diff --git a/app/templates/account/ask_reset_password.html b/app/templates/account/ask_reset_password.html new file mode 100644 index 0000000..631bd2f --- /dev/null +++ b/app/templates/account/ask_reset_password.html @@ -0,0 +1,22 @@ +{% extends "base/base.html" %} + +{% block content %} +
+

Réinitialiser le mot de passe

+ +

Vous recevrez un mail contenant un lien pour réintialiser votre mot de passe.

+ +
+ {{ form.hidden_tag() }} +
+ {{ form.email.label }} +
{{ form.email.description }}
+ {{ form.email() }} + {% for error in form.email.errors %} + {{ error }} + {% endfor %} +
+
{{ form.submit(class_="bg-ok") }}
+
+
+{% endblock %} diff --git a/app/templates/account/login.html b/app/templates/account/login.html index f73f83a..bd84f1b 100644 --- a/app/templates/account/login.html +++ b/app/templates/account/login.html @@ -23,5 +23,6 @@

{{ form.submit(class_="bg-ok") }}

Pas encore de compte ? Créé-en un !

- +

Mot de passe oublié ?

+ {% endblock %} diff --git a/app/templates/account/reset_password.html b/app/templates/account/reset_password.html new file mode 100644 index 0000000..a9a94d2 --- /dev/null +++ b/app/templates/account/reset_password.html @@ -0,0 +1,28 @@ +{% extends "base/base.html" %} + +{% block content %} +
+

Réinitialiser le mot de passe

+ +
+ {{ form.hidden_tag() }} +
+ {{ form.password.label }} +
{{ form.password.description }}
+ {{ form.password() }} + {% for error in form.password.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.password2.label }} +
{{ form.password2.description }}
+ {{ form.password2() }} + {% for error in form.password2.errors %} + {{ error }} + {% endfor %} +
+
{{ form.submit(class_="bg-ok") }}
+
+
+{% endblock %} diff --git a/app/templates/base/navbar/account.html b/app/templates/base/navbar/account.html index ef44842..d34cea4 100644 --- a/app/templates/base/navbar/account.html +++ b/app/templates/base/navbar/account.html @@ -55,7 +55,7 @@ {{ login_form.remember_me.label }} {{ login_form.remember_me() }}
- Mot de passe oublié ? Inscription + Mot de passe oublié ? {% endif %} diff --git a/app/templates/email/activate.html b/app/templates/email/activate.html new file mode 100644 index 0000000..7d9ed2c --- /dev/null +++ b/app/templates/email/activate.html @@ -0,0 +1,14 @@ + + +
Bonjour {{ name }},
+ +
Bienvenue sur Planète Casio !
+ +
Afin de pouvoir vous connecter et utiliser nos services, merci de confirmer cette adresse email en cliquant ici.
+
Si ça ne fonctionne pas, utilisez ce lien : {{ confirm_url }}
+ +
À bientôt sur Planète Casio !
+ +
L'équipe du site.
+ + diff --git a/app/templates/email/activate.md b/app/templates/email/activate.md new file mode 100644 index 0000000..0d9345a --- /dev/null +++ b/app/templates/email/activate.md @@ -0,0 +1,9 @@ +Bonjour {{ name }}, + +Bienvenue sur Planète Casio ! + +Afin de pouvoir vous connecter et utiliser nos services, merci de confirmer cette adresse email en cliquant sur ce lien : {{ confirm_url }} + +À bientôt sur Planète Casio ! + +L'équipe du site. diff --git a/app/templates/email/reset_password.html b/app/templates/email/reset_password.html new file mode 100644 index 0000000..92775e9 --- /dev/null +++ b/app/templates/email/reset_password.html @@ -0,0 +1,16 @@ + + +
Bonjour {{ name }},
+ +
Quelqu'un (probablement vous) a demandé a réinitialiser le mot de passe de ce compte.
+ +
Cliquez ici pour changer le mot de passe.
+
Si ça ne fonctionne pas, utilisez ce lien : {{ reset_url }}
+ +
Si vous n'êtes pas à l'origine de cette opération, vous pouvez ignorer ce message.
+ +
À bientôt sur Planète Casio !
+ +
L'équipe du site.
+ + diff --git a/app/templates/email/reset_password.md b/app/templates/email/reset_password.md new file mode 100644 index 0000000..6683f93 --- /dev/null +++ b/app/templates/email/reset_password.md @@ -0,0 +1,11 @@ +Bonjour {{ name }} + +Quelqu'un (probablement vous) a demandé a réinitialiser le mot de passe de ce compte. + +Cliquez sur ce lien pour changer votre mot de passe : {{ reset_url }} + +Si vous n'êtes pas à l'origine de cette opération, vous pouvez ignorer ce message. + +À bientôt sur Planète Casio ! + +L'équipe du site. diff --git a/app/templates/forum/topic.html b/app/templates/forum/topic.html index 50ba387..8ea0d4e 100644 --- a/app/templates/forum/topic.html +++ b/app/templates/forum/topic.html @@ -1,6 +1,6 @@ {% extends "base/base.html" %} {% import "widgets/editor.html" as widget_editor %} -{% import "widgets/member.html" as widget_member %} +{% import "widgets/user.html" as widget_user %} {% import "widgets/pagination.html" as widget_pagination with context %} {% block title %} @@ -11,11 +11,7 @@

{{ t.title }}

- {% if t.author.type == "member" %} - - {% else %} - - {% endif %} +
{{ widget_member.profile(t.author ) }}Invité{{ t.author.name }}{{ widget_user.profile(t.author ) }} {{ t.thread.top_comment.text }}
@@ -25,11 +21,7 @@ {% for c in comments.items %} {% if c != t.thread.top_comment %} - {% if c.author.type == "member" %} - {{ widget_member.profile(c.author ) }} - {% else %} - Invité{{ c.author.name }} - {% endif %} + {{ widget_user.profile(c.author) }}
{% if c.date_created != c.date_modified %} Posté le {{ c.date_created|date }} (Modifié le {{ c.date_modified|date }}) diff --git a/app/templates/widgets/member.html b/app/templates/widgets/member.html deleted file mode 100644 index 41fefcc..0000000 --- a/app/templates/widgets/member.html +++ /dev/null @@ -1,12 +0,0 @@ -{% macro profile(member) %} -
- Avatar de {{ member.name }} -
- -
Membre
-
Niveau {{ member.level[0] }} ({{ member.xp }})
-
N{{ member.level[0] }} ({{ member.xp }})
-
-
-
-{% endmacro %} diff --git a/app/templates/widgets/user.html b/app/templates/widgets/user.html index 62fd7f7..f3c3091 100644 --- a/app/templates/widgets/user.html +++ b/app/templates/widgets/user.html @@ -1,12 +1,19 @@ -{% macro profile(member) %} -
- Avatar de {{ member.name }} +{% macro profile(user) %} +{% if user.type == "member" %} +
+ Avatar de {{ user.name }}
-
{{ member.name }}
-
Membre
-
Niveau {{ member.level[0] }} ({{ member.xp }})
-
N{{ member.level[0] }} ({{ member.xp }})
-
+
{{ user.name }}
+
Membre
+
Niveau {{ user.level[0] }} ({{ user.xp }})
+
N{{ user.level[0] }} ({{ user.xp }})
+
-
+
+{% else %} +
+ {{ user.name }} + Invité +
+{% endif %} {% endmacro %} diff --git a/app/utils/priv_required.py b/app/utils/priv_required.py index 51564cd..70493af 100644 --- a/app/utils/priv_required.py +++ b/app/utils/priv_required.py @@ -36,3 +36,15 @@ def priv_required(*perms): return func(*args, **kwargs) return wrapped return decorated_view + + +def guest_only(func): + """ + Opposite decorator of @login_required + """ + @wraps(func) + def wrapped(*args, **kwargs): + if current_user.is_authenticated: + abort(404) + return func(*args, **kwargs) + return wrapped diff --git a/app/utils/send_mail.py b/app/utils/send_mail.py new file mode 100644 index 0000000..2044491 --- /dev/null +++ b/app/utils/send_mail.py @@ -0,0 +1,39 @@ +from flask import url_for, render_template +from flask_mail import Message +from app import app, mail +from itsdangerous import URLSafeTimedSerializer +from config import V5Config + +def send_mail(dest, subject, html="", body=""): + m = Message(recipients=[dest], subject=subject, html=html, body=body) + mail.send(m) + +def send_reset_password_mail(name, email): + ts = URLSafeTimedSerializer(app.config["SECRET_KEY"]) + token = ts.dumps(email, salt='email-confirm-key') + reset_url = f"https://{V5Config.DOMAIN}" \ + + url_for('reset_password', token=token) + subject = "Confirmez votre email pour compléter l'inscription" + html = render_template('email/reset_password.html', reset_url=reset_url, + name=name) + body = render_template('email/reset_password.md', reset_url=reset_url, + name=name) + if V5Config.SEND_MAILS: + send_mail(member.email, subject, html=html, body=body) + else: + print(f"Reset password url: {reset_url}") + +def send_validation_mail(name, email): + ts = URLSafeTimedSerializer(app.config["SECRET_KEY"]) + token = ts.dumps(email, salt='email-confirm-key') + confirm_url = f"https://{V5Config.DOMAIN}" \ + + url_for('activate_account', token=token) + subject = "Confirmez votre email pour compléter l'inscription" + html = render_template('email/activate.html', confirm_url=confirm_url, + name=name) + body = render_template('email/activate.md', confirm_url=confirm_url, + name=name) + if V5Config.SEND_MAILS: + send_mail(member.email, subject, html=html, body=body) + else: + print(f"Email confirmation url: {confirm_url}") diff --git a/config.py b/config.py index 69c5deb..f0c0e54 100644 --- a/config.py +++ b/config.py @@ -16,9 +16,14 @@ class Config(object): + LocalConfig.DB_NAME SQLALCHEMY_TRACK_MODIFICATIONS = False + MAIL_DEFAULT_SENDER = "noreply@v5.planet-casio.com" + MAIL_SUPPRESS_SEND = None + class DefaultConfig(object): """Every value here can be overrided in the local_config.py class""" + # Domain + DOMAIN = "v5.planet-casio.com" # Length allocated to privilege names (slugs) PRIVS_MAXLEN = 64 # Forbidden user names @@ -57,6 +62,10 @@ class DefaultConfig(object): AVATARS_FOLDER = '/avatar/folder/' # Enable guest post ENABLE_GUEST_POST = True + # Disable email confimation + ENABLE_EMAIL_CONFIRMATION = True + # Send emails + SEND_MAILS = True class V5Config(LocalConfig, DefaultConfig): # Values put here cannot be overidden with local_config diff --git a/migrations/versions/acf72cf31eea_added_email_validated_property_in_model_.py b/migrations/versions/acf72cf31eea_added_email_validated_property_in_model_.py new file mode 100644 index 0000000..566f3cc --- /dev/null +++ b/migrations/versions/acf72cf31eea_added_email_validated_property_in_model_.py @@ -0,0 +1,28 @@ +"""Added email_validated property in model `Member` + +Revision ID: acf72cf31eea +Revises: 6fd4c15b8a7b +Create Date: 2020-07-21 20:29:44.876704 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'acf72cf31eea' +down_revision = '6fd4c15b8a7b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('member', sa.Column('email_confirmed', sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('member', 'email_confirmed') + # ### end Alembic commands ###