Skip to content
Snippets Groups Projects
Commit 7af0a4b9 authored by Martin Mareš's avatar Martin Mareš
Browse files

Reforma rozesílání mailů

- Máme obecnou funkci na odeslání mailu účastníkovi. V configu se
  dá nastavit, že všechny maily se přesměrovávají na zadanou adresu.

- Všechny maily logujeme.

- Posílání mailů negeneruje výjimky, nýbrž chyby zapisuje do logu.

- Rozlišujeme mail o založení účtu od mailu o resetu hesla. Je v nich
  stejný odkaz, ale liší se popis situace.

- Odkaz na reset hesla se vytváří v odesílacích funkcích. Proto
  z configu potřebujeme znát URL webu.
parent 7b9960ac
No related branches found
No related tags found
No related merge requests found
...@@ -10,4 +10,11 @@ SECRET_KEY = "FIXME" ...@@ -10,4 +10,11 @@ SECRET_KEY = "FIXME"
SESSION_COOKIE_PATH = '/' SESSION_COOKIE_PATH = '/'
SESSION_COOKIE_NAME = 'mo_session' 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/'
...@@ -4,11 +4,13 @@ from dataclasses import dataclass ...@@ -4,11 +4,13 @@ from dataclasses import dataclass
import datetime import datetime
import email.message import email.message
import email.headerregistry import email.headerregistry
import logging
import re import re
import subprocess import subprocess
import sys import sys
from typing import Any, Optional, NoReturn from typing import Any, Optional, NoReturn
import textwrap import textwrap
import urllib.parse
import mo.db as db import mo.db as db
import mo.config as config import mo.config as config
...@@ -17,6 +19,8 @@ current_log_user: Optional[db.User] = None ...@@ -17,6 +19,8 @@ current_log_user: Optional[db.User] = None
def log(type: db.LogType, what: int, details: Any): def log(type: db.LogType, what: int, details: Any):
"""Zapíše záznam do databázového logu."""
entry = db.Log( entry = db.Log(
changed_by=current_log_user.user_id if current_log_user else None, changed_by=current_log_user.user_id if current_log_user else None,
type=type, type=type,
...@@ -26,13 +30,18 @@ def log(type: db.LogType, what: int, details: Any): ...@@ -26,13 +30,18 @@ def log(type: db.LogType, what: int, details: Any):
db.get_session().add(entry) db.get_session().add(entry)
def send_password_reset_email(user: db.User, link: str): def send_user_email(user: db.User, subject: str, body: str) -> bool:
if not hasattr(config, 'MAIL_FROM'): logging.info(f'Mail: "{subject}" -> {user.email}')
raise RuntimeError('V configu chybí pole MAIL_FROM.')
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 = email.message.EmailMessage()
msg['From'] = email.headerregistry.Address( 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'] = [ msg['To'] = [
email.headerregistry.Address( email.headerregistry.Address(
...@@ -40,31 +49,63 @@ def send_password_reset_email(user: db.User, link: str): ...@@ -40,31 +49,63 @@ def send_password_reset_email(user: db.User, link: str):
addr_spec=user.email, addr_spec=user.email,
) )
] ]
msg['Subject'] = 'OSMO – obnova hesla' msg['Subject'] = 'OSMO – ' + subject
msg['Date'] = datetime.datetime.now() 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') 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( sm = subprocess.Popen(
[ [
'/usr/sbin/sendmail', '/usr/sbin/sendmail',
'-oi', '-oi',
'-f', '-f',
config.MAIL_FROM, mail_from,
user.email, send_to,
], ],
stdin=subprocess.PIPE, stdin=subprocess.PIPE,
) )
sm.communicate(msg.as_bytes()) sm.communicate(msg.as_bytes())
if sm.returncode != 0: 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: def die(msg: str) -> NoReturn:
......
...@@ -35,7 +35,7 @@ def login(): ...@@ -35,7 +35,7 @@ def login():
app.logger.error('Login: Neznámý uživatel <%s>', email) app.logger.error('Login: Neznámý uživatel <%s>', email)
error = 'Neznámý uživatel.' error = 'Neznámý uživatel.'
elif form.reset.data: 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) min_time_between_resets = datetime.timedelta(minutes=1)
now = datetime.datetime.now().astimezone() now = datetime.datetime.now().astimezone()
...@@ -44,14 +44,9 @@ def login(): ...@@ -44,14 +44,9 @@ def login():
error = 'Poslední požadavek na obnovení hesla byl odeslán příliš nedávno.' error = 'Poslední požadavek na obnovení hesla byl odeslán příliš nedávno.'
else: else:
token = mo.users.ask_reset_password(user) token = mo.users.ask_reset_password(user)
link = url_for('reset', token=token, _external=True)
db.get_session().commit() db.get_session().commit()
try: mo.util.send_password_reset_email(user, token)
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))
return render_template('reset.html') return render_template('reset.html')
elif not form.passwd.data or not mo.users.check_password(user, form.passwd.data): elif not form.passwd.data or not mo.users.check_password(user, form.passwd.data):
...@@ -116,6 +111,7 @@ def reset(): ...@@ -116,6 +111,7 @@ def reset():
if form.cancel.data: if form.cancel.data:
mo.users.cancel_reset_password(user) mo.users.cancel_reset_password(user)
app.logger.info('Login: Zrušen reset hesla pro uživatele <%s>', user.email)
db.get_session().commit() db.get_session().commit()
return render_template('reset.html', okay='Obnova hesla zrušena.') return render_template('reset.html', okay='Obnova hesla zrušena.')
elif len(form.passwd.data) < 8: elif len(form.passwd.data) < 8:
...@@ -127,6 +123,12 @@ def reset(): ...@@ -127,6 +123,12 @@ def reset():
else: else:
mo.users.do_reset_password(user) mo.users.do_reset_password(user)
mo.users.set_password(user, form.passwd.data) 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() db.get_session().commit()
return redirect(url_for('reset', done=1)) return redirect(url_for('reset', done=1))
......
...@@ -378,14 +378,11 @@ def org_user_new(): ...@@ -378,14 +378,11 @@ def org_user_new():
# Send password (re)set link # Send password (re)set link
token = mo.users.ask_reset_password(new_user) token = mo.users.ask_reset_password(new_user)
link = url_for('reset', token=token, _external=True)
db.get_session().commit() db.get_session().commit()
try: if mo.util.send_new_account_email(new_user, token):
mo.util.send_password_reset_email(new_user, link)
flash('E-mail s odkazem pro nastavení hesla odeslán na {}'.format(new_user.email), 'success') flash('E-mail s odkazem pro nastavení hesla odeslán na {}'.format(new_user.email), 'success')
except RuntimeError as e: else:
app.logger.error('Login: problém při posílání e-mailu: {}'.format(e))
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 pro nastavení hesla', 'danger')
if is_org: if is_org:
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment