From 251bd5f556846d0d58623272c05d678e5e6bb48e Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Fri, 16 Jul 2021 13:11:56 +0200 Subject: [PATCH] =?UTF-8?q?U=C5=BEivatel=C3=A9=20si=20mohou=20m=C4=9Bnit?= =?UTF-8?q?=20heslo=20a=20e-mail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pro změnu obojího je potřeba zadat staré heslo, aby se minimalizovalo riziko únosu účtu, má-li útočník chvíli přístup k session. Změna e-mailové adresy podléhá potvrzení e-mailem. --- mo/util.py | 18 +++- mo/web/auth.py | 131 +++++++++++++++++++++-- mo/web/templates/acct_confirm_email.html | 8 ++ mo/web/templates/settings.html | 31 ++++-- mo/web/templates/settings_change.html | 8 ++ 5 files changed, 182 insertions(+), 14 deletions(-) create mode 100644 mo/web/templates/acct_confirm_email.html create mode 100644 mo/web/templates/settings_change.html diff --git a/mo/util.py b/mo/util.py index d047aee4..42d0a813 100644 --- a/mo/util.py +++ b/mo/util.py @@ -118,7 +118,11 @@ def password_reset_url(token: str) -> str: def confirm_create_url(token: str) -> str: - return config.WEB_ROOT + 'auth/confirm?' + urllib.parse.urlencode({'token': token}, safe=':') + return config.WEB_ROOT + 'auth/confirm/r?' + urllib.parse.urlencode({'token': token}, safe=':') + + +def confirm_email_url(token: str) -> str: + return config.WEB_ROOT + 'auth/confirm/e?' + urllib.parse.urlencode({'token': token}, safe=':') def send_new_account_email(user: db.User, token: str) -> bool: @@ -158,6 +162,18 @@ def send_confirm_create_email(user: db.User, token: str) -> bool: '''.format(confirm_create_url(token)))) +def send_confirm_change_email(user: db.User, token: str) -> bool: + return send_user_email(user, 'Změna e-mailové adresy', textwrap.dedent('''\ + Někdo (pravděpodobně Vy) požádal o nastavení e-mailové adresy k účtu + v Odevzdávacím systému Matematické olympiády na tuto adresu. + Pokud změnu chcete provést, následujte tento odkaz: + + {} + + Váš OSMO + '''.format(confirm_email_url(token)))) + + def die(msg: str) -> NoReturn: print(msg, file=sys.stderr) sys.exit(1) diff --git a/mo/web/auth.py b/mo/web/auth.py index 379fa2df..3ecc1f5c 100644 --- a/mo/web/auth.py +++ b/mo/web/auth.py @@ -118,13 +118,73 @@ def incarnate(id): @app.route('/user/settings') def user_settings(): sess = db.get_session() - roles = [] - if g.user: - roles = (sess.query(db.UserRole) - .filter_by(user_id=g.user.user_id) - .options(joinedload(db.UserRole.place)) - .all()) - return render_template('settings.html', roles=roles, roles_by_type=mo.rights.roles_by_type) + + roles = (sess.query(db.UserRole) + .filter_by(user_id=g.user.user_id) + .options(joinedload(db.UserRole.place)) + .all()) + + if g.user.is_org or g.user.is_admin: + pant = None + else: + pant = sess.query(db.Participant).get((g.user.user_id, mo.current_year)) + + return render_template('settings.html', user=g.user, pant=pant, roles=roles, roles_by_type=mo.rights.roles_by_type) + + +class SettingsForm(FlaskForm): + email = mo_fields.Email(validators=[validators.DataRequired()]) + current_passwd = wtforms.PasswordField('Aktuální heslo', validators=[validators.DataRequired()]) + new_passwd = mo_fields.NewPassword( + description=mo.users.password_help + ' Pokud nechcete heslo měnit, ponechte toto políčko prázdné.', + ) + new_passwd2 = mo_fields.RepeatPassword() + submit = wtforms.SubmitField('Nastavit') + + def validate_current_passwd(form, field): + if not mo.users.check_password(g.user, field.data): + raise ValidationError('Chybné heslo.') + + +@app.route('/user/settings/change', methods=('GET', 'POST')) +def user_settings_change(): + sess = db.get_session() + user = g.user + + form = SettingsForm() + if not form.submit.data: + form.email.data = user.email + + if form.validate_on_submit(): + ok = True + if form.new_passwd.data: + app.logger.info(f'Settings: Změněno heslo uživatele #{user.user_id}') + mo.users.set_password(user, form.new_passwd.data) + mo.util.log( + type=db.LogType.user, + what=user.user_id, + details={'action': 'change-passwd'}, + ) + sess.commit() + flash('Heslo změněno.', 'success') + if form.email.data != user.email: + rr = mo.users.new_reg_request(db.RegReqType.change, request.remote_addr) + if rr: + rr.user_id = user.user_id + rr.email = form.email.data + sess.add(rr) + sess.commit() + app.logger.info(f'Settings: Požadavek na změnu e-mailu uživatele #{user.user_id}') + flash('Odeslán e-mail s odkazem na potvrzení nové adresy.', 'success') + mo.util.send_confirm_change_email(user, rr.email_token) + else: + app.logger.info('Settings: Rate limit') + flash('Příliš mnoho požadavků na změny hesla. Počkejte prosím chvíli a zkuste to znovu.', 'danger') + ok = False + if ok: + return redirect(url_for('user_settings')) + + return render_template('settings_change.html', form=form) @app.errorhandler(NeedLoginError) @@ -361,6 +421,11 @@ class Reg2: RegStatus.already_spent: 'Tento odkaz na potvrzení registrace byl již využit.', RegStatus.already_exists: 'Účet s touto adresou už existuje.', }, + db.RegReqType.change: { + RegStatus.new: 'Chybný potvrzovací kód. Zkontrolujte, že jste odkaz z e-mailu zkopírovali správně.', + RegStatus.expired: 'Vypršela platnost potvrzovacího kódu, požádejte prosím o změnu e-mailu znovu.', + RegStatus.already_spent: 'Tento odkaz na potvrzení změny e-mailu byl již využit.', + }, } def __init__(self, token: str, expected_type: db.RegReqType): @@ -445,6 +510,28 @@ class Reg2: self.user = user return True + def change_email(self): + sess = db.get_session() + user = g.user + user.email = self.rr.email + + app.logger.info(f'Reg2: Uživatel #{user.user_id} si změnil email na <{user.email}>') + mo.util.log( + type=db.LogType.user, + what=user.user_id, + details={ + 'action': 'change-settings', + 'changes': db.get_object_changes(user), + }, + ) + + self.rr.used_at = mo.now + sess.commit() + + def spend_request(self): + self.rr.used_at = mo.now + db.get_session().commit() + def flash_message(self): msgs = self.messages[self.reg_type] if self.status in msgs: @@ -484,6 +571,36 @@ def confirm_reg(): return render_template('acct_reg2.html', form=form) +class ConfirmEmailForm(FlaskForm): + orig_email = wtforms.StringField('Původní e-mail', render_kw={"disabled": "disabled"}) + new_email = wtforms.StringField('Nový e-mail', render_kw={"disabled": "disabled"}) + submit = wtforms.SubmitField('Potvrdit změnu') + cancel = wtforms.SubmitField('Zrušit požadavek') + + +@app.route('/auth/confirm/e', methods=('GET', 'POST')) +def confirm_email(): + reg2 = Reg2(request.args.get('token'), db.RegReqType.change) + if reg2.status != RegStatus.ok: + reg2.flash_message() + return redirect(url_for('user_settings')) + + form = ConfirmEmailForm() + if form.validate_on_submit(): + if form.submit.data: + reg2.change_email() + flash('E-mail změněn.', 'success') + elif form.cancel.data: + reg2.spend_request() + flash('Požadavek na změnu e-mailu zrušen.', 'success') + return redirect(url_for('user_settings')) + + form.orig_email.data = g.user.email + form.new_email.data = reg2.rr.email + + return render_template('acct_confirm_email.html', form=form) + + @app.errorhandler(werkzeug.exceptions.Forbidden) def handle_forbidden(e): return render_template('forbidden.html') diff --git a/mo/web/templates/acct_confirm_email.html b/mo/web/templates/acct_confirm_email.html new file mode 100644 index 00000000..616790bd --- /dev/null +++ b/mo/web/templates/acct_confirm_email.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} +{% block title %}Změna e-mailu{% endblock %} +{% block body %} + +{{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }} + +{% endblock %} diff --git a/mo/web/templates/settings.html b/mo/web/templates/settings.html index 654eff58..32c42fa0 100644 --- a/mo/web/templates/settings.html +++ b/mo/web/templates/settings.html @@ -1,14 +1,34 @@ {% extends "base.html" %} -{% block title %}Uživatel {{ g.user.full_name() }}{% endblock %} +{% block title %}Uživatel {{ user.full_name() }}{% endblock %} {% block body %} + <h3>Osobní údaje</h3> + + <table class=table> + <tr><th>Jméno<td>{{ user.first_name }} + <tr><th>Příjmení<td>{{ user.last_name }} + <tr><th>E-mail<td>{{ user.email }} +{% if pant %} + <tr><th>Škola<td>{{ pant.school_place.name }} + <tr><th>Ročník<td>{{ pant.grade }} + <tr><th>Rok narození<td>{{ pant.birth_year }} +{% endif %} + </table> + + <p><a class='btn btn-primary' href='{{ url_for('user_settings_change') }}'>Změnit e-mail nebo heslo</a> + + <p>Pokud potřebujete změnit jiné údaje, ozvěte se svému učiteli nebo garantovi. + Neuspějete-li u nich, napište správci OSMO (kontakt viz patička stránky). + +{% if user.is_admin or user.is_org %} + <h3>Práva</h3> -{% if g.user.is_admin %} +{% if user.is_admin %} <p>Správce systému {% endif %} -{% if g.user.is_org %} +{% if user.is_org %} <p>Organizátor s následujícími rolemi: - <table class=data> + <table class=table> <tr> <th>Role <th>Oblast @@ -25,8 +45,7 @@ {% endfor %} </table> {% endif %} -{% if not g.user.is_admin and not g.user.is_org %} - <p>Běžný uživatel + {% endif %} {% endblock %} diff --git a/mo/web/templates/settings_change.html b/mo/web/templates/settings_change.html new file mode 100644 index 00000000..568ca0fa --- /dev/null +++ b/mo/web/templates/settings_change.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} +{% block title %}Změna osobních údajů{% endblock %} +{% block body %} + +{{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }} + +{% endblock %} -- GitLab