From f84702804a02cc684184c5e6524cfda47104a75d Mon Sep 17 00:00:00 2001 From: Martin Mares <mj@ucw.cz> Date: Sun, 25 Jul 2021 23:41:56 +0200 Subject: [PATCH] =?UTF-8?q?Reset=20hesla=20odd=C4=9Blen=20od=20aktivace=20?= =?UTF-8?q?=C3=BA=C4=8Dtu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reset hesla používá rate-limitované tokeny uložené v DB. Aktivace používá podepsané tokeny s timestampem, ale už funguje jen na účty, ke kterým se dosud nikdo nepřihlásil. Org už nemůže požádat o reset hesla uživatele, jen o znovuposlání aktivačního odkazu. Timestamp resetu hesla v DB změnil sémantiku, teď se aktualizuje jak při provedených resetech/aktivacích, tak při žádostech o ně. --- bin/create-user | 9 +- bin/reset-user | 13 +-- db/db.ddl | 2 +- mo/imports.py | 2 +- mo/users.py | 87 ++++++++---------- mo/util.py | 8 +- mo/web/auth.py | 115 ++++++++++++++---------- mo/web/org_contest.py | 2 + mo/web/org_users.py | 28 +++--- mo/web/templates/acct_activate.html | 8 ++ mo/web/templates/acct_reset_passwd.html | 9 ++ mo/web/templates/org_user.html | 2 +- mo/web/templates/reset.html | 17 ---- 13 files changed, 152 insertions(+), 150 deletions(-) create mode 100644 mo/web/templates/acct_activate.html create mode 100644 mo/web/templates/acct_reset_passwd.html delete mode 100644 mo/web/templates/reset.html diff --git a/bin/create-user b/bin/create-user index 09fbe9e1..c6f86c6b 100755 --- a/bin/create-user +++ b/bin/create-user @@ -13,8 +13,7 @@ parser.add_argument(dest='first_name', help='křestní jméno (jedno nebo více) parser.add_argument(dest='last_name', help='příjmení (jedno nebo více)') parser.add_argument('--org', default=False, action='store_true', help='přidělí uživateli organizátorská práva') parser.add_argument('--admin', default=False, action='store_true', help='přidělí uživateli správcovská práva') -parser.add_argument('--passwd', type=str, help='nastaví počáteční heslo') -parser.add_argument('--mail', default=False, action='store_true', help='pošle uživateli mail o založení účtu') +parser.add_argument('--passwd', type=str, help='nastaví počáteční heslo (jinak pošle aktivační mail)') args = parser.parse_args() email = mo.users.normalize_email(args.email) @@ -45,11 +44,9 @@ mo.util.log(db.LogType.user, user.user_id, { if args.passwd is not None: mo.users.set_password(user, args.passwd) - -if args.mail: - token = mo.users.ask_reset_password(user) + token = mo.users.make_activation_token(user) session.commit() -if args.mail: +if args.passwd is None: mo.util.send_new_account_email(user, token) diff --git a/bin/reset-user b/bin/reset-user index 0b6f8c70..88bb6953 100755 --- a/bin/reset-user +++ b/bin/reset-user @@ -1,16 +1,14 @@ #!/usr/bin/env python3 import argparse -import sys -import mo.config as config +import mo.config import mo.db as db import mo.users import mo.util -parser = argparse.ArgumentParser(description='Resetuje uživateli heslo a pošle mail') +parser = argparse.ArgumentParser(description='Pošle uživateli nový aktivační mail') parser.add_argument(dest='email', help='e-mailová adresa') -parser.add_argument('--new', default=False, action='store_true', help='pošle mail o založení účtu') parser.add_argument('--mail-instead', metavar='EMAIL', default=None, help='pošle mail někomu jinému') args = parser.parse_args() @@ -22,13 +20,10 @@ user = mo.users.user_by_email(args.email) if user is None: mo.util.die('Tento uživatel neexistuje') -token = mo.users.ask_reset_password(user) +token = mo.users.make_activation_token(user) session.commit() if args.mail_instead: mo.config.MAIL_INSTEAD = args.mail_instead -if args.new: - mo.util.send_new_account_email(user, token) -else: - mo.util.send_password_reset_email(user, token) +mo.util.send_new_account_email(user, token) diff --git a/db/db.ddl b/db/db.ddl index 0800fb38..9979743c 100644 --- a/db/db.ddl +++ b/db/db.ddl @@ -15,7 +15,7 @@ CREATE TABLE users ( is_test boolean NOT NULL DEFAULT false, -- testovací účastník, není vidět ve výsledkovkách created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, last_login_at timestamp with time zone DEFAULT NULL, - reset_at timestamp with time zone DEFAULT NULL, -- poslední požadavek na reset hesla + reset_at timestamp with time zone DEFAULT NULL, -- poslední reset/aktivace nebo žádost o ně password_hash varchar(255) DEFAULT NULL, -- heš hesla (je-li nastaveno) note text NOT NULL DEFAULT '' -- poznámka viditelná pro orgy ); diff --git a/mo/imports.py b/mo/imports.py index 33284537..3645ea65 100644 --- a/mo/imports.py +++ b/mo/imports.py @@ -372,7 +372,7 @@ class Import: for uid in self.new_user_ids: u = sess.query(db.User).get(uid) if u and not u.password_hash and not u.reset_at: - token = mo.users.ask_reset_password(u) + token = mo.users.make_activation_token(u) sess.commit() mo.util.send_new_account_email(u, token) else: diff --git a/mo/users.py b/mo/users.py index 7e0c90b3..44667834 100644 --- a/mo/users.py +++ b/mo/users.py @@ -2,6 +2,7 @@ import bcrypt import datetime +import dateutil.tz import email.errors import email.headerregistry import re @@ -174,12 +175,17 @@ def validate_password(passwd: str) -> bool: return len(passwd) >= 8 -def set_password(user: db.User, passwd: str): +def set_password(user: db.User, passwd: str, reset: bool = False): salt = bcrypt.gensalt() hashed = bcrypt.hashpw(passwd.encode('utf-8'), salt) user.password_hash = hashed.decode('us-ascii') - user.reset_at = None - user.last_login_at = datetime.datetime.now() + if reset: + user.reset_at = mo.now + mo.util.log( + type=db.LogType.user, + what=user.user_id, + details={'action': 'do-reset'}, + ) def check_password(user: db.User, passwd: str): @@ -188,63 +194,30 @@ def check_password(user: db.User, passwd: str): def login(user: db.User): - user.last_login_at = datetime.datetime.now() - user.reset_at = None + user.last_login_at = mo.now -def ask_reset_password(user: db.User) -> str: - user.reset_at = datetime.datetime.now() - when = int(user.reset_at.timestamp()) - token = mo.tokens.sign_token([str(user.user_id), str(when)], 'reset') - - mo.util.log( - type=db.LogType.user, - what=user.user_id, - details={'action': 'ask-reset'}, - ) - - return token +def make_activation_token(user: db.User) -> str: + user.reset_at = mo.now + when = int(mo.now.timestamp()) + return mo.tokens.sign_token([str(user.user_id), str(when)], 'activate') -def check_reset_password(token: str) -> Optional[db.User]: +def check_activation_token(token: str) -> Optional[db.User]: token = mo.util.clean_up_token(token) - fields = mo.tokens.verify_token(token, 'reset') + fields = mo.tokens.verify_token(token, 'activate') if not fields or len(fields) != 2: return None - user = db.get_session().query(db.User).filter_by(user_id=int(fields[0])).first() + user_id = int(fields[0]) + token_time = datetime.datetime.fromtimestamp(int(fields[1]), tz=dateutil.tz.UTC) - if user.password_hash is None: - reset_token_validity_time = datetime.timedelta(days=28) + user = user_by_uid(user_id) + if not user: + return None + elif token_time < mo.now - datetime.timedelta(days=28): + return None else: - reset_token_validity_time = datetime.timedelta(hours=24) - - now = datetime.datetime.now().astimezone() - - if (user - and user.reset_at is not None - and fields[1] == str(int(user.reset_at.timestamp())) - and now - user.reset_at < reset_token_validity_time): return user - else: - return None - - -def cancel_reset_password(user: db.User): - user.reset_at = None - mo.util.log( - type=db.LogType.user, - what=user.user_id, - details={'action': 'cancel-reset'}, - ) - - -def do_reset_password(user: db.User): - user.reset_at = None - mo.util.log( - type=db.LogType.user, - what=user.user_id, - details={'action': 'do-reset'}, - ) def new_reg_request(type: db.RegReqType, client: str) -> Optional[db.RegRequest]: @@ -272,3 +245,17 @@ def expire_reg_requests(): table = db.RegRequest.__table__ conn.execute(table.delete().where(table.c.expires_at < mo.now)) sess.commit() + + +def request_reset_password(user: db.User, client: str) -> Optional[db.RegRequest]: + logger.info('Login: Požadavek na reset hesla pro <%s>', user.email) + rr = new_reg_request(db.RegReqType.reset, client) + if rr: + db.get_session().add(rr) + rr.user_id = user.user_id + mo.util.log( + type=db.LogType.user, + what=user.user_id, + details={'action': 'ask-reset'}, + ) + return rr diff --git a/mo/util.py b/mo/util.py index 42d0a813..d22fadb4 100644 --- a/mo/util.py +++ b/mo/util.py @@ -113,8 +113,12 @@ def send_user_email(user: db.User, subject: str, body: str) -> bool: return True +def activate_url(token: str) -> str: + return config.WEB_ROOT + 'auth/activate?' + urllib.parse.urlencode({'token': token}, safe=':') + + def password_reset_url(token: str) -> str: - return config.WEB_ROOT + 'auth/reset?' + urllib.parse.urlencode({'token': token}, safe=':') + return config.WEB_ROOT + 'auth/confirm/p?' + urllib.parse.urlencode({'token': token}, safe=':') def confirm_create_url(token: str) -> str: @@ -135,7 +139,7 @@ def send_new_account_email(user: db.User, token: str) -> bool: {} Váš OSMO - '''.format(password_reset_url(token)))) + '''.format(activate_url(token)))) def send_password_reset_email(user: db.User, token: str) -> bool: diff --git a/mo/web/auth.py b/mo/web/auth.py index 3ecc1f5c..7b547e59 100644 --- a/mo/web/auth.py +++ b/mo/web/auth.py @@ -62,20 +62,13 @@ def login(): app.logger.error('Login: Neznámý uživatel <%s>', email) flash('Neznámý uživatel', 'danger') elif form.reset.data: - app.logger.info('Login: Požadavek na reset hesla pro <%s>', email) - - min_time_between_resets = datetime.timedelta(minutes=1) - now = datetime.datetime.now().astimezone() - if (user.reset_at is not None - and now - user.reset_at < min_time_between_resets): - flash('Poslední požadavek na obnovení hesla byl odeslán příliš nedávno', 'danger') - else: - token = mo.users.ask_reset_password(user) + rr = mo.users.request_reset_password(user, request.remote_addr) + if rr: db.get_session().commit() - - mo.util.send_password_reset_email(user, token) - flash('Na uvedenou adresu byl odeslán e-mail s odkazem na obnovu hesla', 'success') - + mo.util.send_password_reset_email(user, rr.email_token) + flash('Na uvedenou adresu byl odeslán e-mail s odkazem na obnovu hesla.', 'success') + else: + flash('Příliš časté požadavky na obnovu hesla.', 'danger') elif not form.passwd.data or not mo.users.check_password(user, form.passwd.data): app.logger.error('Login: Špatné heslo pro uživatele <%s>', email) flash('Chybné heslo', 'danger') @@ -179,7 +172,7 @@ def user_settings_change(): 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') + flash('Příliš mnoho požadavků na změny e-mailu. Počkejte prosím chvíli a zkuste to znovu.', 'danger') ok = False if ok: return redirect(url_for('user_settings')) @@ -195,53 +188,38 @@ def handle_need_login(e): class ResetForm(FlaskForm): - email = EmailField('E-mail', description='Účet pro který se nastavuje nové heslo', render_kw={"disabled": "disabled"}) - token = wtforms.HiddenField() - passwd = wtforms.PasswordField('Nové heslo', description=mo.users.password_help) + email = wtforms.StringField('E-mail', description='Účet pro který se nastavuje nové heslo', render_kw={"disabled": "disabled"}) + new_passwd = mo_fields.NewPassword(validators=[validators.DataRequired()]) + new_passwd2 = mo_fields.RepeatPassword(validators=[validators.DataRequired()]) submit = wtforms.SubmitField('Nastavit heslo') - cancel = wtforms.SubmitField('Zrušit obnovu hesla') -@app.route('/auth/reset', methods=('GET', 'POST')) -def reset(): +@app.route('/auth/activate', methods=('GET', 'POST')) +def activate(): token = request.args.get('token') if not token: - flash('Žádný token pro resetování hesla', 'danger') + flash('Chybí token pro aktivaci účtu', 'danger') return redirect(url_for('login')) - user = mo.users.check_reset_password(token) + user = mo.users.check_activation_token(token) if not user: - flash('Neplatný požadavek na obnovu hesla', 'danger') + flash('Neplatný kód pro aktivaci účtu. Zkontrolujte, že jste odkaz z e-mailu zkopírovali správně.', 'danger') + return redirect(url_for('login')) + + if user.last_login_at is not None: + flash('Tento účet už byl aktivován. Pokud neznáte heslo, použijte tlačítko pro obnovu hesla.', 'danger') return redirect(url_for('login')) - form = ResetForm(token=token, email=user.email) + form = ResetForm(email=user.email) ok = form.validate_on_submit() if not ok: - return render_template('reset.html', form=form) + return render_template('acct_activate.html', form=form) - if form.cancel.data: - mo.users.cancel_reset_password(user) - app.logger.info('Login: Zrušen reset hesla pro uživatele <%s>', user.email) - db.get_session().commit() - flash('Obnova hesla zrušena', 'warning') - return redirect(url_for('login')) - elif not mo.users.validate_password(form.passwd.data): - flash(mo.users.password_help, 'danger') - return render_template('reset.html', form=form) - else: - mo.users.do_reset_password(user) - mo.users.set_password(user, form.passwd.data) - app.logger.info('Login: Reset hesla pro uživatele <%s>', user.email) - mo.util.log( - type=db.LogType.user, - what=user.user_id, - details={'action': 'reset-passwd'}, - ) - mo.users.login(user) - app.logger.info('Login: Přihlásil se uživatel <%s> po resetování hesla', user.email) - db.get_session().commit() - flash('Nastavení nového hesla a přihlášení do systému proběhlo úspěšně', 'success') - return login_and_redirect(user, flash_msg='Heslo nastaveno') + app.logger.info('Login: Aktivace účtu uživatele <%s>', user.email) + mo.users.set_password(user, form.new_passwd.data, reset=True) + mo.users.login(user) + db.get_session().commit() + return login_and_redirect(user, flash_msg='Nastavení nového hesla a přihlášení do systému proběhlo úspěšně') class RegStatus(Enum): @@ -426,6 +404,11 @@ class Reg2: 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.', }, + db.RegReqType.reset: { + RegStatus.new: 'Chybný kód pro obnovení hesla. Zkontrolujte, že jste odkaz z e-mailu zkopírovali správně.', + RegStatus.expired: 'Vypršela platnost kódu pro obnovení hesla, požádejte prosím o obnovu znovu.', + RegStatus.already_spent: 'Tento odkaz na obnovení hesla byl již využit.', + }, } def __init__(self, token: str, expected_type: db.RegReqType): @@ -528,6 +511,17 @@ class Reg2: self.rr.used_at = mo.now sess.commit() + def change_passwd(self, new_passwd: str): + sess = db.get_session() + user = self.rr.user + + app.logger.info(f'Reg2: Uživatel #{user.user_id} si resetoval heslo') + mo.users.set_password(user, new_passwd, reset=True) + mo.users.login(user) + + self.rr.used_at = mo.now + sess.commit() + def spend_request(self): self.rr.used_at = mo.now db.get_session().commit() @@ -601,6 +595,31 @@ def confirm_email(): return render_template('acct_confirm_email.html', form=form) +class CancelResetForm(FlaskForm): + cancel = wtforms.SubmitField('Zrušit obnovu hesla') + + +@app.route('/auth/confirm/p', methods=('GET', 'POST')) +def confirm_reset(): + reg2 = Reg2(request.args.get('token'), db.RegReqType.reset) + if reg2.status != RegStatus.ok: + reg2.flash_message() + return redirect(url_for('login')) + + form = ResetForm(email=reg2.rr.user.email) + if form.validate_on_submit() and form.submit.data: + reg2.change_passwd(form.new_passwd.data) + return login_and_redirect(reg2.rr.user, flash_msg='Nastavení nového hesla a přihlášení do systému proběhlo úspěšně') + + cform = CancelResetForm() + if cform.validate_on_submit() and cform.cancel.data: + reg2.spend_request() + flash('Požadavek na změnu hesla zrušen.', 'success') + return redirect(url_for('user_settings')) + + return render_template('acct_reset_passwd.html', form=form, cancel_form=cform) + + @app.errorhandler(werkzeug.exceptions.Forbidden) def handle_forbidden(e): return render_template('forbidden.html') diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py index 28c42d63..03e23ada 100644 --- a/mo/web/org_contest.py +++ b/mo/web/org_contest.py @@ -1674,6 +1674,8 @@ def org_contest_add_user(id: int, site_id: Optional[int] = None): db.get_session().commit() if is_new_user: flash("Založen nový uživatel.", "info") + token = mo.users.make_activation_token(user) + mo.email.send_new_account_email(user, token) if is_new_participant: flash("Založena nová registrace do ročníku.", "info") if is_new_participation: diff --git a/mo/web/org_users.py b/mo/web/org_users.py index e885537a..981d8c0b 100644 --- a/mo/web/org_users.py +++ b/mo/web/org_users.py @@ -316,14 +316,15 @@ class ResendInviteForm(FlaskForm): resend_invite = SubmitField() def do(self, user: db.User): - token = mo.users.ask_reset_password(user) - db.get_session().commit() - if user.last_login_at is None and mo.util.send_new_account_email(user, token): - flash('Uvítací e-mail s odkazem pro nastavení hesla odeslán na {}'.format(user.email), 'success') - elif mo.util.send_password_reset_email(user, token): - flash('E-mail s odkazem pro resetování hesla odeslán na {}'.format(user.email), 'success') + if user.last_login_at is None: + token = mo.users.make_activation_token(user) + db.get_session().commit() + if mo.util.send_new_account_email(user, token): + flash('Uvítací e-mail s odkazem na aktivaci účtu odeslán na {}.'.format(user.email), 'success') + else: + flash('Problém při odesílání e-mailu s odkazem na aktivaci účtu.', 'danger') else: - flash('Problém při odesílání e-mailu s odkazem pro nastavení hesla', 'danger') + flash('Tento uživatel už má účet aktivovaný.', 'danger') @app.route('/org/org/<int:id>/', methods=('GET', 'POST')) @@ -339,7 +340,7 @@ def org_org(id: int): can_assign_rights = rr.have_right(Right.assign_rights) resend_invite_form: Optional[ResendInviteForm] = None - if rr.can_edit_user(user): + if user.last_login_at is None and rr.can_edit_user(user): resend_invite_form = ResendInviteForm() if resend_invite_form.resend_invite.data and resend_invite_form.validate_on_submit(): resend_invite_form.do(user) @@ -425,7 +426,7 @@ def org_user(id: int): rr = g.gatekeeper.rights_generic() resend_invite_form: Optional[ResendInviteForm] = None - if rr.can_edit_user(user): + if user.last_login_at is None and rr.can_edit_user(user): resend_invite_form = ResendInviteForm() if resend_invite_form.resend_invite.data and resend_invite_form.validate_on_submit(): resend_invite_form.do(user) @@ -567,17 +568,14 @@ def org_user_new(): details={'action': 'new', 'user': db.row2dict(new_user)}, ) + token = mo.users.make_activation_token(new_user) sess.commit() flash('Nový uživatel vytvořen', 'success') - # Send password (re)set link - token = mo.users.ask_reset_password(new_user) - db.get_session().commit() - if mo.util.send_new_account_email(new_user, token): - flash('E-mail s odkazem pro nastavení hesla odeslán na {}'.format(new_user.email), 'success') + flash('E-mail s odkazem na aktivaci účtu odeslán na {}.'.format(new_user.email), 'success') else: - flash('Problém při odesílání e-mailu s odkazem pro nastavení hesla', 'danger') + flash('Problém při odesílání e-mailu s odkazem na aktivaci účtu.', 'danger') if is_org: return redirect(url_for('org_org', id=new_user.user_id)) diff --git a/mo/web/templates/acct_activate.html b/mo/web/templates/acct_activate.html new file mode 100644 index 00000000..1117b6c9 --- /dev/null +++ b/mo/web/templates/acct_activate.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} +{% block title %}Aktivace nového účtu{% endblock %} +{% block body %} + +{{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }} + +{% endblock %} diff --git a/mo/web/templates/acct_reset_passwd.html b/mo/web/templates/acct_reset_passwd.html new file mode 100644 index 00000000..1342edea --- /dev/null +++ b/mo/web/templates/acct_reset_passwd.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} +{% block title %}Nastavení nového hesla{% endblock %} +{% block body %} + +{{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }} +{{ wtf.quick_form(cancel_form, form_type='horizontal') }} + +{% endblock %} diff --git a/mo/web/templates/org_user.html b/mo/web/templates/org_user.html index 966d4b94..05463d5f 100644 --- a/mo/web/templates/org_user.html +++ b/mo/web/templates/org_user.html @@ -24,7 +24,7 @@ <form method=POST class='btn-group' onsubmit='return confirm("Poslat účastníkovi e-mail s odkazem na vytvoření hesla?");'> {{ resend_invite_form.csrf_token }} <button class="btn btn-default" type='submit' name='resend_invite' value='yes'> - {% if user.last_login_at %}Resetovat heslo{% else %}Znovu poslat zvací e-mail{% endif %} + Znovu poslat zvací e-mail </button> </form> {% endif %} diff --git a/mo/web/templates/reset.html b/mo/web/templates/reset.html deleted file mode 100644 index bb382b0a..00000000 --- a/mo/web/templates/reset.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "base.html" %} -{% import "bootstrap/wtf.html" as wtf %} -{% block title %}Nastavení nového hesla{% endblock %} -{% block body %} - - <form method="POST" class="form form-horizontal" action=""> - {{ form.csrf_token }} - {{ form.token() }} - {{ wtf.form_field(form.email, form_type='horizontal') }} - {{ wtf.form_field(form.passwd, form_type='horizontal') }} - <div class="btn-group col-lg-offset-2"> - {{ wtf.form_field(form.submit, class="btn btn-primary") }} - {{ wtf.form_field(form.cancel) }} - </div> - </form> - -{% endblock %} -- GitLab