Skip to content

Commit b3b713e

Browse files
Merge pull request #125 from rigdenlab/development
Automate user account recovery
2 parents a7965f5 + dbb93b4 commit b3b713e

File tree

9 files changed

+140
-7
lines changed

9 files changed

+140
-7
lines changed

app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ def recover_account(n_clicks, username, email, secret, password_1, password_2):
201201
if not callback_utils.ensure_triggered(trigger):
202202
return no_update
203203

204-
return app_utils.recover_account(username, email, secret, password_1, password_2)
204+
return app_utils.recover_account(username, email, secret, password_1, password_2, app.logger)
205205

206206
@app.callback([Output('invalid-create-user-collapse', 'is_open'),
207207
Output('create-user-modal-div', 'children'),

components/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,18 @@ def InvalidPasswordRecoverAccount(*args, **kwargs):
622622
return InvalidPasswordRecoverAccount(*args, **kwargs)
623623

624624

625+
def ContactWrongAccountModal(*args, **kwargs):
626+
from components.modals import ContactWrongAccountModal
627+
628+
return ContactWrongAccountModal(*args, **kwargs)
629+
630+
631+
def ContactRecoverAccountModal(*args, **kwargs):
632+
from components.modals import ContactRecoverAccountModal
633+
634+
return ContactRecoverAccountModal(*args, **kwargs)
635+
636+
625637
def FailureRecoverAccount(*args, **kwargs):
626638
from components.modals import FailureRecoverAccount
627639

components/modals.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,29 @@ def SlackConnectionErrorModal():
601601
], id='slack-connection-error-modal', is_open=True)
602602

603603

604+
def ContactWrongAccountModal():
605+
return dbc.Modal([
606+
dbc.ModalHeader(
607+
html.H4('Wrong account details', className="alert-heading", style={'color': 'red'})),
608+
dbc.ModalBody(
609+
html.P("""We cannot match the information you provided with any account on our database. If you wish to
610+
recover your password please make sure to provide the same email address and username you have registered
611+
when creating the account.""", style={'text-align': "justify"})
612+
),
613+
], id='slack-fail-recovery-modal', is_open=True)
614+
615+
616+
def ContactRecoverAccountModal():
617+
return dbc.Modal([
618+
dbc.ModalHeader(
619+
html.H4('Account recovery', className="alert-heading", style={'color': 'green'})),
620+
dbc.ModalBody(
621+
html.P("""We have sent a message to your email address with further instructions to reset your password.
622+
PLEASE MAKE SURE TO CHECK YOUR SPAM FOLDER.""", style={'text-align': "justify"})
623+
),
624+
], id='slack-success-recovery-modal', is_open=True)
625+
626+
604627
def PaletteModal(dataset, idx):
605628
palette_dict = {}
606629
palette_list = []

requirements.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,7 @@ keydb~=0.0.1
1515
psycopg2-binary~=2.8.5
1616
slack~=0.0.2
1717
slackclient~=2.6.1
18-
visdcc~=0.0.40
18+
visdcc~=0.0.40
19+
yagmail~=0.14.245
20+
keyring~=22.0.1
21+
keyrings.cryptfile~=1.3.6

utils/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ class WimleyWhiteHydrophobicityScale(Enum):
2626

2727

2828
def conplot_version():
29-
return 'v0.3'
29+
return 'v0.3.1'
3030

3131

3232
def get_base_url():
@@ -104,6 +104,8 @@ class UrlIndex(Enum):
104104
GDPR_WEBSITE = 'https://gdpr-info.eu'
105105
DOCKER_HUB = 'https://hub.docker.com/r/filosanrod/conplot'
106106
CONPLOT_DOCKER = 'https://github.com/rigdenlab/conplot-docker'
107+
CONPLOT_MAIL = '[email protected]'
108+
CONPLOT_USERNAME = 'conplot.noreply'
107109

108110

109111
def create_ConPlot(*args, **kwargs):

utils/app_utils.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,18 @@
66
from utils.exceptions import IntegrityError, UserExists, EmailAlreadyUsed
77

88

9-
def recover_account(username, email, secret, password_1, password_2):
9+
def recover_account(username, email, secret, password_1, password_2, logger):
1010

1111
if password_1 != password_2:
1212
return components.InvalidPasswordRecoverAccount()
1313

1414
success = postgres_utils.recover_account(username, email, secret, password_1)
1515

1616
if success:
17+
logger.info('Username {} reset password successful'.format(username))
1718
return components.SuccessRecoverAccount()
1819

20+
logger.info('Username {} failed to reset password'.format(username))
1921
return components.FailureRecoverAccount()
2022

2123

utils/callback_utils.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from dash import no_update
55
from components import EmailIssueReference
66
from loaders import DatasetReference, AdditionalDatasetReference
7-
from utils import slack_utils, decompress_data, cache_utils
7+
from utils import slack_utils, decompress_data, cache_utils, email_utils, postgres_utils
88

99

1010
class DatasetIndex(Enum):
@@ -46,6 +46,7 @@ def get_remove_trigger(trigger):
4646
dataset = index[1]
4747
return fname, dataset, is_open
4848

49+
4950
def is_user_login(trigger):
5051
prop_id = json.loads(trigger['prop_id'].replace('.n_clicks', ''))
5152
index = prop_id['idx']
@@ -109,10 +110,24 @@ def get_session_action(trigger):
109110
def submit_form(name, email, subject, description, logger):
110111
if not name or not email or not description or not subject:
111112
return components.InvalidContactFormModal()
112-
elif slack_utils.user_get_in_touch(name, email, subject, description, logger):
113+
114+
elif subject != EmailIssueReference.FORGOT_PSSWRD.value:
115+
slack_success = slack_utils.user_get_in_touch(name, email, subject, description, logger)
116+
if not slack_success:
117+
return components.SlackConnectionErrorModal()
113118
return components.SuccessContactFormModal()
119+
114120
else:
115-
return components.SlackConnectionErrorModal()
121+
secret = postgres_utils.activate_recovery_mode(name, email)
122+
if secret is None:
123+
return components.ContactWrongAccountModal()
124+
125+
email_success = email_utils.acount_recovery(name, email, secret, logger)
126+
slack_success = slack_utils.user_get_in_touch(name, email, subject, description, logger)
127+
if not email_success or not slack_success:
128+
return components.SlackConnectionErrorModal()
129+
130+
return components.ContactRecoverAccountModal()
116131

117132

118133
def retrieve_contact_fnames(session_id, cache):

utils/email_utils.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import os
2+
import yagmail
3+
import keyring
4+
from keyrings.cryptfile.cryptfile import CryptFileKeyring
5+
from utils import UrlIndex
6+
7+
8+
def no_keyring():
9+
kr = keyring.get_keyring()
10+
if not isinstance(kr, CryptFileKeyring):
11+
return True
12+
return False
13+
14+
15+
def set_keyring():
16+
kr = CryptFileKeyring()
17+
kr.keyring_key = os.environ["KEYRING_CRYPTFILE_PSSWRD"]
18+
keyring.set_keyring(kr)
19+
20+
21+
def send_email(recipient, subject, contents):
22+
with yagmail.SMTP(UrlIndex.CONPLOT_USERNAME.value) as yag:
23+
yag.send(to=recipient, subject=subject, contents=contents)
24+
25+
26+
def register_mail():
27+
yagmail.register(UrlIndex.CONPLOT_MAIL.value, os.environ['MAIL_PSSWRD'])
28+
29+
30+
def acount_recovery(username, email, secret, logger):
31+
subject = 'ConPlot password recovery'
32+
33+
body = """
34+
Dear ConPlot user,
35+
36+
We are sending this email because you have requested to reset your password. To regain access to your account, please
37+
go to www.conplot.org/contact and complete the form. You will need to include the following verification code:
38+
39+
Verification Code: {}
40+
41+
This is an automated email, please do not reply to this message. To get in touch with us again, use to the form at
42+
www.conplot.org/account-recovery
43+
44+
Best wishes,
45+
The ConPlot Team
46+
""".format(secret)
47+
48+
try:
49+
if no_keyring():
50+
set_keyring()
51+
register_mail()
52+
send_email(email, subject, body)
53+
logger.info('Sent email to {} - {} for password recovery'.format(username, email))
54+
return True
55+
except Exception as e:
56+
logger.error('Cannot send recovery email to {} - {}. Exception found: {}'.format(username, email, e))
57+
return False

utils/postgres_utils.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import uuid
23
from operator import attrgetter
34
from collections import namedtuple
45
from components import SessionListType
@@ -60,6 +61,13 @@ class SqlQueries(Enum):
6061
""".format(TableNames.USER_DATA.value, SqlFieldNames.PASSWORD.value, SqlFieldNames.PASSWORD.value,
6162
SqlFieldNames.USERNAME.value)
6263

64+
CHECK_USER_AND_EMAIL = """SELECT * FROM {} WHERE {} = %s AND {} = %s
65+
""".format(TableNames.USER_DATA.value, SqlFieldNames.USERNAME.value, SqlFieldNames.EMAIL.value)
66+
67+
ACTIVATE_RECOVERY = """UPDATE {} SET {} = crypt(%s, {}) WHERE {} = %s AND {} = %s
68+
""".format(TableNames.USER_DATA.value, SqlFieldNames.RECOVERY.value, SqlFieldNames.RECOVERY.value,
69+
SqlFieldNames.USERNAME.value, SqlFieldNames.EMAIL.value)
70+
6371
CHECK_IS_RECOVERY = """SELECT {} FROM {} WHERE {} = crypt(%s, {}) AND {} = %s AND {} = %s
6472
""".format(SqlFieldNames.ID.value, TableNames.USER_DATA.value, SqlFieldNames.RECOVERY.value,
6573
SqlFieldNames.RECOVERY.value, SqlFieldNames.USERNAME.value, SqlFieldNames.EMAIL.value)
@@ -190,6 +198,17 @@ def recover_account(username, email, secret, new_password):
190198
return False
191199

192200

201+
def activate_recovery_mode(username, email):
202+
rslt = perform_query(SqlQueries.CHECK_USER_AND_EMAIL.value, args=(username, email), fetch=True)
203+
if not rslt:
204+
return None
205+
206+
secret = str(uuid.uuid4())
207+
perform_query(SqlQueries.ACTIVATE_RECOVERY.value, args=(secret, username, email), commit=True)
208+
209+
return secret
210+
211+
193212
def store_session(username, session_name, session):
194213
session = prepare_session_storage(session)
195214

0 commit comments

Comments
 (0)