Source code for authnzerver.actions.email

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# actions_email.py - Waqas Bhatti (wbhatti@astro.princeton.edu) - Aug 2018
# License: MIT - see the LICENSE file for the full text.

'''This contains functions to drive email-related auth actions.

'''

#############
## LOGGING ##
#############

import logging

# get a logger
LOGGER = logging.getLogger(__name__)


#############
## IMPORTS ##
#############

try:

    from datetime import datetime, timezone, timedelta
    utc = timezone.utc

except Exception:

    from datetime import datetime, timedelta, tzinfo
    ZERO = timedelta(0)

    class UTC(tzinfo):
        """UTC"""

        def utcoffset(self, dt):
            return ZERO

        def tzname(self, dt):
            return "UTC"

        def dst(self, dt):
            return ZERO

    utc = UTC()

import multiprocessing as mp
from email.mime.text import MIMEText
from email.utils import formatdate, make_msgid
import smtplib
import time

from sqlalchemy import select

from .. import authdb
from .session import auth_session_exists
from ..permissions import pii_hash


####################
## SENDING EMAILS ##
####################

SIGNUP_VERIFICATION_EMAIL_SUBJECT = (
    '[{server_name}] Please verify your account sign up request'
)
SIGNUP_VERIFICATION_EMAIL_TEMPLATE = '''\
Hello,

This is an automated message from the {server_name} at: {server_baseurl}.

We received an account sign up request for: {user_email}. This request
was made using the browser:

{browser_identifier}

from the IP address: {ip_address}.

Please enter this code:

{verification_code}

into the account verification form at: {server_baseurl}{account_verify_url}

to verify that you made this request. This code will expire on

{verification_expiry}

You will also need to enter your email address and password
to log in.

If you do not recognize the browser and IP address above or did not
initiate this request, someone else may have used your email address
in error. Feel free to ignore this email.

You can see your IP address here: https://www.google.com/search?q=my+ip+address

Thanks,
{server_name} admins
{server_baseurl}
'''


FORGOTPASS_VERIFICATION_EMAIL_SUBJECT = (
    '[{server_name}] Please verify your password reset request'
)
FORGOTPASS_VERIFICATION_EMAIL_TEMPLATE = '''\
Hello,

This is an automated message from the {server_name} at: {server_baseurl}.

We received a password reset request for: {user_email}. This request
was initiated using the browser:

{browser_identifier}

from the IP address: {ip_address}.

Please enter this code:

{verification_code}

into the account verification form at: {server_baseurl}{password_forgot_url}

to verify that you made this request. This code will expire on

{verification_expiry}

If you do not recognize the browser and IP address above or did not
initiate this request, someone else may have used your email address
in error. Feel free to ignore this email.

You can see your IP address here: https://www.google.com/search?q=my+ip+address

Thanks,
{server_name} admins
{server_baseurl}
'''


CHANGEPASS_VERIFICATION_EMAIL_SUBJECT = (
    '[{server_name}] Please verify your password change request'
)
CHANGEPASS_VERIFICATION_EMAIL_TEMPLATE = '''\
Hello,

This is an automated message from the {server_name} at: {server_baseurl}.

We received a password change request for: {user_email}. This request
was initiated using the browser:

{browser_identifier}

from the IP address: {ip_address}.

Please enter this code:

{verification_code}

into the account verification form at: {server_baseurl}{password_change_url}

to verify that you made this request. This code will expire on

{verification_expiry}

If you do not recognize the browser and IP address above or did not
initiate this request, someone else may have used your email address
in error. Feel free to ignore this email.

You can see your IP address here: https://www.google.com/search?q=my+ip+address

Thanks,
{server_name} admins
{server_baseurl}
'''


[docs]def authnzerver_send_email( sender, subject, text, recipients, server, user, password, pii_salt, port=587 ): ''' This is a utility function to send email. Parameters ---------- sender : str The name and email address of the entity sending the email in the following form:: "Sender Name <senderemail@example.com>" subject : str The subject of the email. text : str The text of the email. recipients : list of str A list of the email addresses to send the email to. Use either of the formats below for each email address:: "Recipient Name <recipient@example.com>" "recipient@example.com" server : str The address of the email server to use. user : str The username to use when logging into the email server via SMTP. password : str The password to use when logging into the email server via SMTP. pii_salt : str The PII salt value passed in from a wrapping function. Used to censor personally identifying information in the logs emitted from this function. port : int The SMTP port to use when logging into the email server via SMTP. Returns ------- bool Returns True if email sending succeeded. False otherwise. ''' msg = MIMEText(text) msg['From'] = sender msg['To'] = ', '.join(recipients) msg['Message-Id'] = make_msgid() msg['Subject'] = subject msg['Date'] = formatdate(time.time()) # next, we'll try to login to the SMTP server try: server = smtplib.SMTP(server, port) server.ehlo() if server.has_extn('STARTTLS'): try: server.starttls() server.ehlo() server.login( user, password ) server.sendmail( sender, recipients, msg.as_string() ) server.quit() return True except Exception as e: LOGGER.error( "Could not send the email to recipients: %s " "with subject: %s because of an exception: %r" % (', '.join([pii_hash(x, pii_salt) for x in recipients]), subject, e) ) server.quit() return False else: LOGGER.error('Email server: %s does not support TLS, ' 'will not send an insecure email.' % server) server.quit() return False except Exception as e: LOGGER.error( "Could not send the email to recipients: %s " "with subject: %s because of an exception: %r" % (', '.join([pii_hash(x, pii_salt) for x in recipients]), subject, e) ) server.quit() return False
[docs]def send_signup_verification_email(payload, raiseonfail=False, override_authdb_path=None): '''This actually sends the verification email. Parameters ----------- payload : dict Keys expected in this dict from a client are: - email_address: str, the email address to send the email to - session_token: str, session token of the user being sent the email - created_info: str, the dict returned by ``users.auth_create_user()`` - server_name: str, the name of the frontend server - server_baseurl: str, the base URL of the frontend server - account_verify_url: str, the URL fragment of the frontend verification endpoint - verification_token: str, a verification token generated by frontend - verification_expiry: int, number of seconds after which the token expires In addition, the following keys must be provided by a wrapper function to set up the email server. - smtp_user - smtp_pass - smtp_server - smtp_port - smtp_sender Finally, the payload must also include the following keys (usually added in by a wrapping function): - reqid: int or str - pii_salt: str override_authdb_path : str or None If given as a str, is the alternative path to the auth DB. raiseonfail : bool If True, will raise an Exception if something goes wrong. Returns ------- dict Returns a dict containing the user_id, email_address, and the verifyemail_sent_datetime value if email was sent successfully. ''' for key in ('reqid','pii_salt'): if key not in payload: LOGGER.error( "Missing %s in payload dict. Can't process this request." % key ) return { 'success':False, 'user_id':None, 'email_address':None, 'verifyemail_sent_datetime':None, 'messages':["Invalid verify email request."], } for key in ('email_address', 'session_token', 'server_name', 'server_baseurl', 'account_verify_url', 'verification_token', 'verification_expiry', 'smtp_sender', 'smtp_user', 'smtp_pass', 'smtp_server', 'smtp_port', 'created_info'): if key not in payload: LOGGER.error( '[%s] Invalid verify email request, missing %s.' % (payload['reqid'], key) ) return { 'success':False, 'user_id':None, 'email_address':None, 'verifyemail_sent_datetime':None, 'messages':([ "Invalid verify email request." ]) } # check if we don't need to send an email to this user if payload['created_info']['send_verification'] is False: LOGGER.error( '[%s] Verify email request failed for ' 'user_id: %s, email: %s, session_token: %s.' 'Not allowed to send a verification email to this user.' % (payload['reqid'], pii_hash(payload['created_info']['user_id'], payload['pii_salt']), pii_hash(payload['email_address'], payload['pii_salt']), pii_hash(payload['session_token'], payload['pii_salt'])) ) return { 'success':False, 'user_id':None, 'email_address':None, 'verifyemail_sent_datetime':None, 'messages':([ "Not allowed to send an email verification request." ]) } # this checks if the database connection is live currproc = mp.current_process() engine = getattr(currproc, 'authdb_engine', None) if override_authdb_path: currproc.auth_db_path = override_authdb_path if not engine: currproc.authdb_engine, currproc.authdb_conn, currproc.authdb_meta = ( authdb.get_auth_db( currproc.auth_db_path, echo=raiseonfail ) ) users = currproc.authdb_meta.tables['users'] # first, we'll verify the user was created successfully, their account is # currently set to inactive and their role is 'locked'. then, we'll verify # if the session token provided exists and get the IP address and the # browser identifier out of it. # look up the provided user user_sel = select([ users.c.user_id, users.c.email, users.c.is_active, users.c.user_role, ]).select_from(users).where( users.c.email == payload['email_address'] ).where( users.c.user_id == payload['created_info']['user_id'] ) user_results = currproc.authdb_conn.execute(user_sel) user_info = user_results.fetchone() user_results.close() if not user_info: LOGGER.error( '[%s] Verify email request failed for ' 'user_id: %s, email: %s, session_token: %s.' 'The specified user does not exist.' % (payload['reqid'], pii_hash(payload['created_info']['user_id'], payload['pii_salt']), pii_hash(payload['email_address'], payload['pii_salt']), pii_hash(payload['session_token'], payload['pii_salt'])) ) return { 'success':False, 'user_id':None, 'email_address':None, 'verifyemail_sent_datetime':None, 'messages':([ "Invalid verify email request." ]) } if user_info['is_active'] or user_info['user_role'] != 'locked': LOGGER.error( '[%s] Verify email request failed for ' 'user_id: %s, email: %s, session_token: %s.' 'The specified user is already active and ' 'does not need a verification email.' % (payload['reqid'], pii_hash(payload['created_info']['user_id'], payload['pii_salt']), pii_hash(payload['email_address'], payload['pii_salt']), pii_hash(payload['session_token'], payload['pii_salt'])) ) return { 'success':False, 'user_id':None, 'email_address':None, 'verifyemail_sent_datetime':None, 'messages':([ "Not sending an verify email request to an existing user." ]) } # check the session session_info = auth_session_exists( {'session_token':payload['session_token'], 'pii_salt':payload['pii_salt'], 'reqid':payload['reqid']}, raiseonfail=raiseonfail, override_authdb_path=override_authdb_path ) if not session_info['success']: LOGGER.error( '[%s] Verify email request failed for ' 'user_id: %s, email: %s, session_token: %s.' 'The session requesting a verify email is not valid.' % (payload['reqid'], pii_hash(payload['created_info']['user_id'], payload['pii_salt']), pii_hash(payload['email_address'], payload['pii_salt']), pii_hash(payload['session_token'], payload['pii_salt'])) ) return { 'success':False, 'user_id':None, 'email_address':None, 'verifyemail_sent_datetime':None, 'messages':([ "Invalid verify email request." ]) } # get the IP address and browser ID from the session ip_addr = session_info['session_info']['ip_address'] browser = session_info['session_info']['user_agent'] # TODO: we'll use geoip to get the location of the person who initiated the # request. # get the verification token's expiry datetime verification_expiry_td = timedelta(seconds=payload['verification_expiry']) verification_expiry_dt = ( datetime.utcnow() + verification_expiry_td ).isoformat() # generate the email message msgtext = SIGNUP_VERIFICATION_EMAIL_TEMPLATE.format( server_baseurl=payload['server_baseurl'], server_name=payload['server_name'], account_verify_url=payload['account_verify_url'], verification_code=payload['verification_token'], verification_expiry='%s (UTC time)' % verification_expiry_dt, browser_identifier=browser.replace('_','.'), ip_address=ip_addr, user_email=payload['email_address'], ) sender = payload['smtp_sender'] recipients = [user_info['email']] subject = SIGNUP_VERIFICATION_EMAIL_SUBJECT.format( server_name=payload['server_name'] ) # send the email email_sent = authnzerver_send_email( sender, subject, msgtext, recipients, payload['smtp_server'], payload['smtp_user'], payload['smtp_pass'], payload['pii_salt'], port=payload['smtp_port'] ) if email_sent: emailverify_sent_datetime = datetime.utcnow() # finally, we'll update the users table with the actual # verifyemail_sent_datetime if sending succeeded. upd = users.update( ).where( users.c.user_id == payload['created_info']['user_id'] ).where( users.c.is_active.is_(False) ).where( users.c.email == payload['created_info']['user_email'] ).values({ 'emailverify_sent_datetime': emailverify_sent_datetime, }) result = currproc.authdb_conn.execute(upd) result.close() LOGGER.info( '[%s] Verify email request succeeded for ' 'user_id: %s, email: %s, session_token: %s.' 'Email sent on: %s UTC.' % (payload['reqid'], pii_hash(payload['created_info']['user_id'], payload['pii_salt']), pii_hash(payload['email_address'], payload['pii_salt']), pii_hash(payload['session_token'], payload['pii_salt']), emailverify_sent_datetime.isoformat()) ) return { 'success':True, 'user_id':user_info['user_id'], 'email_address':user_info['email'], 'verifyemail_sent_datetime':emailverify_sent_datetime, 'messages':([ "Verify email sent successfully." ]) } else: LOGGER.error( '[%s] Verify email request failed for ' 'user_id: %s, email: %s, session_token: %s.' 'The email server could not send the email ' 'to the specified address.' % (payload['reqid'], pii_hash(payload['created_info']['user_id'], payload['pii_salt']), pii_hash(payload['email_address'], payload['pii_salt']), pii_hash(payload['session_token'], payload['pii_salt'])) ) return { 'success':False, 'user_id':None, 'email_address':None, 'verifyemail_sent_datetime':None, 'messages':([ "Could not send email for the verify email request." ]) }
[docs]def verify_user_email_address(payload, raiseonfail=False, override_authdb_path=None): '''Sets the verification status of the email address of the user. This is called by the frontend after it verifies that the token challenge to verify the user's email succeeded and has not yet expired. This will set the user_role to 'authenticated' and the is_active column to True. Parameters ---------- payload : dict This is a dict with the following key: - email Finally, the payload must also include the following keys (usually added in by a wrapping function): - reqid: int or str - pii_salt: str override_authdb_path : str or None If given as a str, is the alternative path to the auth DB. raiseonfail : bool If True, will raise an Exception if something goes wrong. Returns ------- dict Returns a dict containing the user_id, is_active, and user_role values if verification status is successfully set. ''' for key in ('reqid','pii_salt'): if key not in payload: LOGGER.error( "Missing %s in payload dict. Can't process this request." % key ) return { 'success':False, 'user_id':None, 'is_active':False, 'user_role':'locked', 'messages':["Invalid email verification toggle request."], } if 'email' not in payload: LOGGER.error( '[%s] Invalid email verification toggle request, missing %s.' % (payload['reqid'], 'email') ) return { 'success':False, 'user_id':None, 'is_active': False, 'user_role':'locked', 'messages':["Invalid email verification toggle request."] } # this checks if the database connection is live currproc = mp.current_process() engine = getattr(currproc, 'authdb_engine', None) if override_authdb_path: currproc.auth_db_path = override_authdb_path if not engine: currproc.authdb_engine, currproc.authdb_conn, currproc.authdb_meta = ( authdb.get_auth_db( currproc.auth_db_path, echo=raiseonfail ) ) users = currproc.authdb_meta.tables['users'] # update the table for this user upd = users.update( ).where( users.c.is_active.is_(False) ).where( users.c.email == payload['email'] ).values({ 'is_active':True, 'email_verified':True, 'user_role':'authenticated' }) result = currproc.authdb_conn.execute(upd) sel = select([ users.c.user_id, users.c.is_active, users.c.user_role, ]).select_from(users).where( (users.c.email == payload['email']) ) result = currproc.authdb_conn.execute(sel) rows = result.fetchone() result.close() if rows: LOGGER.info( '[%s] Email verification toggle request succeeded for ' 'user_id: %s, email: %s, role: %s, is_active: %s.' % (payload['reqid'], pii_hash(rows['user_id'], payload['pii_salt']), pii_hash(payload['email'], payload['pii_salt']), pii_hash(rows['user_role'], payload['pii_salt']), rows['is_active']) ) return { 'success':True, 'user_id':rows['user_id'], 'is_active':rows['is_active'], 'user_role':rows['user_role'], 'messages':["Email verification toggle request succeeded."] } else: LOGGER.error( '[%s] Email verification toggle request failed for ' 'email: %s.' 'The database rows corresponding to ' 'the user could not be updated.' % (payload['reqid'], pii_hash(rows['user_id'], payload['pii_salt'])) ) return { 'success':False, 'user_id':None, 'is_active':False, 'user_role':'locked', 'messages':["Email verification toggle request failed."] }
############################## ## FORGOT PASSWORD HANDLING ## ##############################
[docs]def send_forgotpass_verification_email(payload, raiseonfail=False, override_authdb_path=None): '''This actually sends the forgot password email. Parameters ----------- payload : dict Keys expected in this dict from a client are: - email_address: str, the email address to send the email to - session_token: str, session token of the user being sent the email - server_name: str, the name of the frontend server - server_baseurl: str, the base URL of the frontend server - password_forgot_url: str, the URL fragment of the frontend forgot-password process initiation endpoint - verification_token: str, a verification token generated by frontend - verification_expiry: int, number of seconds after which the token expires In addition, the following keys must be provided by a wrapper function to set up the email server. - smtp_user - smtp_pass - smtp_server - smtp_port - smtp_sender Finally, the payload must also include the following keys (usually added in by a wrapping function): - reqid: int or str - pii_salt: str override_authdb_path : str or None If given as a str, is the alternative path to the auth DB. raiseonfail : bool If True, will raise an Exception if something goes wrong. Returns ------- dict Returns a dict containing the user_id, email_address, and the forgotemail_sent_datetime value if email was sent successfully. ''' for key in ('reqid','pii_salt'): if key not in payload: LOGGER.error( "Missing %s in payload dict. Can't process this request." % key ) return { 'success':False, 'user_id':None, 'email_address':None, 'forgotemail_sent_datetime':None, 'messages':["Invalid forgot-password email request."], } for key in ('email_address', 'session_token', 'server_name', 'server_baseurl', 'password_forgot_url', 'verification_token', 'verification_expiry', 'smtp_sender', 'smtp_user', 'smtp_pass', 'smtp_server', 'smtp_port'): if key not in payload: LOGGER.error( '[%s] Invalid forgot-password request, missing %s.' % (payload['reqid'], key) ) return { 'success':False, 'user_id':None, 'email_address':None, 'forgotemail_sent_datetime':None, 'messages':([ "Invalid forgot-password email request." ]) } # this checks if the database connection is live currproc = mp.current_process() engine = getattr(currproc, 'authdb_engine', None) if override_authdb_path: currproc.auth_db_path = override_authdb_path if not engine: currproc.authdb_engine, currproc.authdb_conn, currproc.authdb_meta = ( authdb.get_auth_db( currproc.auth_db_path, echo=raiseonfail ) ) users = currproc.authdb_meta.tables['users'] user_sel = select([ users.c.user_id, users.c.email, users.c.is_active, users.c.user_role, users.c.emailforgotpass_sent_datetime, ]).select_from(users).where( users.c.email == payload['email_address'] ).where( users.c.is_active.is_(True) ).where( users.c.user_role != 'locked' ).where( users.c.user_role != 'anonymous' ) user_results = currproc.authdb_conn.execute(user_sel) user_info = user_results.fetchone() user_results.close() if not user_info: LOGGER.error( "[%s] Forgot-password email request failed for " "email: %s, session_token: %s." "User matching the provided email address " "doesn't exist or is not active." % (payload['reqid'], pii_hash(payload['email_address'], payload['pii_salt']), pii_hash(payload['session_token'], payload['pii_salt'])) ) return { 'success':False, 'user_id':None, 'email_address':None, 'forgotemail_sent_datetime':None, 'messages':([ "Invalid password reset email request." ]) } # check the last time we sent a forgot password email to this user if user_info['emailforgotpass_sent_datetime'] is not None: check_elapsed = ( datetime.utcnow() - user_info['emailforgotpass_sent_datetime'] ) > timedelta(hours=24) if check_elapsed: send_email = True else: send_email = False # if we've never sent a forgot-password email before, it's OK to send it else: send_email = True if not send_email: LOGGER.error( "[%s] Forgot-password email request failed for " "email: %s, session_token: %s." "A forgot-password email was already sent to " "this user within the last 24 hours." % (payload['reqid'], pii_hash(payload['email_address'], payload['pii_salt']), pii_hash(payload['session_token'], payload['pii_salt'])) ) return { 'success':False, 'user_id':None, 'email_address':None, 'forgotemail_sent_datetime':None, 'messages':([ "Invalid password reset email request." ]) } # check the session session_info = auth_session_exists( {'session_token':payload['session_token'], 'pii_salt':payload['pii_salt'], 'reqid':payload['reqid']}, raiseonfail=raiseonfail, override_authdb_path=override_authdb_path ) if not session_info['success']: LOGGER.error( "[%s] Forgot-password email request failed for " "email: %s, session_token: %s." "The session associated with the request is not valid." % (payload['reqid'], pii_hash(payload['email_address'], payload['pii_salt']), pii_hash(payload['session_token'], payload['pii_salt'])) ) return { 'success':False, 'user_id':None, 'email_address':None, 'verifyemail_sent_datetime':None, 'messages':([ "Invalid verification email request." ]) } # # finally! we'll process the email sending request # # get the IP address and browser ID from the session ip_addr = session_info['session_info']['ip_address'] browser = session_info['session_info']['user_agent'] # TODO: we'll use geoip to get the location of the person who initiated the # request. # get the verification token's expiry datetime verification_expiry_td = timedelta(seconds=payload['verification_expiry']) verification_expiry_dt = ( datetime.utcnow() + verification_expiry_td ).isoformat() # generate the email message msgtext = FORGOTPASS_VERIFICATION_EMAIL_TEMPLATE.format( server_baseurl=payload['server_baseurl'], password_forgot_url=payload['password_forgot_url'], server_name=payload['server_name'], verification_code=payload['verification_token'], verification_expiry='%s (UTC time)' % verification_expiry_dt, browser_identifier=browser.replace('_','.'), ip_address=ip_addr, user_email=payload['email_address'], ) sender = payload['smtp_sender'] recipients = [user_info['email']] subject = FORGOTPASS_VERIFICATION_EMAIL_SUBJECT.format( server_name=payload['server_name'] ) # send the email email_sent = authnzerver_send_email( sender, subject, msgtext, recipients, payload['smtp_server'], payload['smtp_user'], payload['smtp_pass'], payload['pii_salt'], port=payload['smtp_port'] ) if email_sent: emailforgotpass_sent_datetime = datetime.utcnow() # finally, we'll update the users table with the actual # verifyemail_sent_datetime if sending succeeded. upd = users.update( ).where( users.c.is_active.is_(True) ).where( users.c.email == payload['email_address'] ).values({ 'emailforgotpass_sent_datetime': emailforgotpass_sent_datetime, }) result = currproc.authdb_conn.execute(upd) result.close() LOGGER.info( '[%s] Forgot-password email request succeeded for ' 'email: %s, session_token: %s.' 'Email sent on: %s UTC.' % (payload['reqid'], pii_hash(payload['email_address'], payload['pii_salt']), pii_hash(payload['session_token'], payload['pii_salt']), emailforgotpass_sent_datetime.isoformat()) ) return { 'success':True, 'user_id':user_info['user_id'], 'email_address':user_info['email'], 'forgotemail_sent_datetime':emailforgotpass_sent_datetime, 'messages':([ "Password reset request sent successfully to %s" % recipients ]) } else: LOGGER.error( '[%s] Forgot-password email request failed for ' 'email: %s, session_token: %s.' 'The email server could not send the ' 'email to the specified address.' % (payload['reqid'], pii_hash(payload['email_address'], payload['pii_salt']), pii_hash(payload['session_token'], payload['pii_salt'])) ) return { 'success':False, 'user_id':None, 'email_address':None, 'verifyemail_sent_datetime':None, 'messages':([ "Could not send email to %s for " "the user password reset request." % recipients ]) }