diff --git a/bin/create-user b/bin/create-user
index 09fbe9e10eaa73c52eef42de7a76e1383aee81e2..c6f86c6b7a46e8926b8cb233143170689e8e4195 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 0b6f8c7085edba31ab12e3d3b63f13d417d7c463..88bb695387b4719eb526d01f81155af4f855a4a7 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 0800fb38b33163bc4755ad7b2c6ae3a91aacfb78..9979743c546aa4ebea291414e3e99d9189d228b8 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 332845371d95707b1e7413dc63a52414fe76b523..3645ea65502bbec806413bc7606b4c99e128c10c 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 7e0c90b3f0adb40d1e7eae5ad6883a9fa9b891bb..44667834dd485e841765366eb24da8700f03ccbf 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 42d0a813fb13934c43b1c03d2fafc8efff4e6ae1..d22fadb49285b81bbf6da2e32481a9a06f7b52bc 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 3ecc1f5cf5cf5db474de975e2f7fde5f51415535..7b547e59fb0858b653717e45f94e69167d5768f4 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 28c42d6394ded15d6c582e0b7eefe7dfca707c1f..03e23adafb19db083dac6b78e07e1773cc5ea367 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 e885537a033c07024aed303dd034d15903535b95..981d8c0b4b424259542a7b473bc03e5b7e192fa2 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 0000000000000000000000000000000000000000..1117b6c929457c315e2a02dd79f7beb42bb87d4b
--- /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 0000000000000000000000000000000000000000..1342edeada97075dacdba3ecbe58076d737b5551
--- /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 966d4b94591f593b6f012aca424ba3a5ddaabc6b..05463d5fedf7d748cda5aef70109032cae7decbb 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 bb382b0a53a39d60c42807dcbc086493163dcdb6..0000000000000000000000000000000000000000
--- 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 %}