diff --git a/etc/config.py.example b/etc/config.py.example index 9644174652047277b2cf09437cdfe6d491adda30..5f2d3ba6f50c5a51d4a99016aad6ce647d562002 100644 --- a/etc/config.py.example +++ b/etc/config.py.example @@ -10,4 +10,11 @@ SECRET_KEY = "FIXME" SESSION_COOKIE_PATH = '/' SESSION_COOKIE_NAME = 'mo_session' -MAIL_FROM = "osmo-auto@mo.mff.cuni.cz" +# Odesilatel generovaných mailů (není-li definován, neposílají se) +# MAIL_FROM = "osmo-auto@mo.mff.cuni.cz" + +# Pro testování je možné všechny odesílané maily přesměrovat na jinou adresu +# MAIL_INSTEAD = "mares@kam.mff.cuni.cz" + +# URL, na kterém aplikace běží +WEB_ROOT = 'https://mo.mff.cuni.cz/osmo-test/' diff --git a/mo/util.py b/mo/util.py index 7b361c1604c50fe75e4bcbfa6401ddd8069f662e..e3a6a582b37d0c6d59ce1d3182d30e7e8cb117a1 100644 --- a/mo/util.py +++ b/mo/util.py @@ -4,11 +4,13 @@ from dataclasses import dataclass import datetime import email.message import email.headerregistry +import logging import re import subprocess import sys from typing import Any, Optional, NoReturn import textwrap +import urllib.parse import mo.db as db import mo.config as config @@ -17,6 +19,8 @@ current_log_user: Optional[db.User] = None def log(type: db.LogType, what: int, details: Any): + """Zapíše záznam do databázového logu.""" + entry = db.Log( changed_by=current_log_user.user_id if current_log_user else None, type=type, @@ -26,13 +30,18 @@ def log(type: db.LogType, what: int, details: Any): db.get_session().add(entry) -def send_password_reset_email(user: db.User, link: str): - if not hasattr(config, 'MAIL_FROM'): - raise RuntimeError('V configu chybí pole MAIL_FROM.') +def send_user_email(user: db.User, subject: str, body: str) -> bool: + logging.info(f'Mail: "{subject}" -> {user.email}') + + mail_from = getattr(config, 'MAIL_FROM', None) + if mail_from is None: + logging.error('Mail: V configu chybí nastavení MAIL_FROM') + return False msg = email.message.EmailMessage() msg['From'] = email.headerregistry.Address( - display_name='Odevzdávací Systém MO', addr_spec=config.MAIL_FROM + display_name='Odevzdávací Systém MO', + addr_spec=mail_from, ) msg['To'] = [ email.headerregistry.Address( @@ -40,31 +49,63 @@ def send_password_reset_email(user: db.User, link: str): addr_spec=user.email, ) ] - msg['Subject'] = 'OSMO – obnova hesla' + msg['Subject'] = 'OSMO – ' + subject msg['Date'] = datetime.datetime.now() - body = textwrap.dedent(''' - Pro obnovení hesla pro váš účet v Odevzávacím Systému MO klikněte sem: {} - - Váš OSMO - '''.format(link)) - msg.set_content(body, cte='quoted-printable') + mail_instead = getattr(config, 'MAIL_INSTEAD', None) + if mail_instead is None: + send_to = user.email + else: + send_to = mail_instead + sm = subprocess.Popen( [ '/usr/sbin/sendmail', '-oi', '-f', - config.MAIL_FROM, - user.email, + mail_from, + send_to, ], stdin=subprocess.PIPE, ) sm.communicate(msg.as_bytes()) if sm.returncode != 0: - raise RuntimeError('Sendmail failed with return code {}'.format(sm.returncode)) + logging.error('Mail: Sendmail failed with return code {}'.format(sm.returncode)) + return False + + return True + + +def password_reset_url(token: str) -> str: + return config.WEB_ROOT + 'auth/reset?' + urllib.parse.urlencode({'token': token}, safe=':') + + +def send_new_account_email(user: db.User, token: str) -> bool: + return send_user_email(user, 'Založen nový účet', textwrap.dedent('''\ + Vítejte! + + Právě Vám byl založen účet v Odevzávacím systému Matematické olympiády. + Nastavte si prosím heslo na následující stránce: + + {} + + Váš OSMO + '''.format(password_reset_url(token)))) + + +def send_password_reset_email(user: db.User, token: str) -> bool: + return send_user_email(user, 'Obnova hesla', textwrap.dedent('''\ + Někdo (pravděpodobně Vy) požádal o obnovení hesla k Vašemu účtu v Odevzávacím + systému Matematické olympiády. Heslo si můžete nastavit, případně požadavek + zrušit, na následující stránce: + + {} + + Váš OSMO + '''.format(password_reset_url(token)))) def die(msg: str) -> NoReturn: diff --git a/mo/web/main.py b/mo/web/main.py index f90e1df6b1c58c7bd22e36e51887f1972527cbfe..a82aea9f7e275fb2d5deaf00fb97a62634253051 100644 --- a/mo/web/main.py +++ b/mo/web/main.py @@ -35,7 +35,7 @@ def login(): app.logger.error('Login: Neznámý uživatel <%s>', email) error = 'Neznámý uživatel.' elif form.reset.data: - app.logger.info('Login: Požadavek na změnu hesla pro <%s>', email) + 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() @@ -44,14 +44,9 @@ def login(): error = 'Poslední požadavek na obnovení hesla byl odeslán příliš nedávno.' else: token = mo.users.ask_reset_password(user) - link = url_for('reset', token=token, _external=True) db.get_session().commit() - try: - mo.util.send_password_reset_email(user, link) - except RuntimeError as e: - app.logger.error('Login: problém při posílání emailu: {}'.format(e)) - + mo.util.send_password_reset_email(user, token) return render_template('reset.html') elif not form.passwd.data or not mo.users.check_password(user, form.passwd.data): @@ -116,6 +111,7 @@ def reset(): 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() return render_template('reset.html', okay='Obnova hesla zrušena.') elif len(form.passwd.data) < 8: @@ -127,6 +123,12 @@ def reset(): 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'}, + ) db.get_session().commit() return redirect(url_for('reset', done=1)) diff --git a/mo/web/org_users.py b/mo/web/org_users.py index 536eb295e193a2de44eeb6cce606297f7f55be26..09b301f4463f4a56307ef6a8db33d4691b8a2f77 100644 --- a/mo/web/org_users.py +++ b/mo/web/org_users.py @@ -378,14 +378,11 @@ def org_user_new(): # Send password (re)set link token = mo.users.ask_reset_password(new_user) - link = url_for('reset', token=token, _external=True) db.get_session().commit() - try: - mo.util.send_password_reset_email(new_user, link) + 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') - except RuntimeError as e: - app.logger.error('Login: problém při posílání e-mailu: {}'.format(e)) + else: flash('Problém při odesílání e-mailu s odkazem pro nastavení hesla', 'danger') if is_org: