$ mkdir twilio-verify-tests $ cd twilio-verify-tests $ python3 -m venv venv $ source venv/bin/activate (venv) $ _
$ mkdir twilio-verify-tests $ cd twilio-verify-tests $ python3 -m venv venv $ venv\Scripts\activate (venv) $ _
(venv) $ pip install "twilio>=6.17.0"
from twilio.rest import Client client = Client('<your Twilio Account SID here>', '<your Twilio Auth Token here>') verify = client.verify.services('<your Twilio Verify Service SID here>') verify.verifications.create(to='<your phone number here>', channel='sms')
’+12345678900’
, where +1
is the country code, 234 is the area code, and 567-8900 is the local number.channel='call'
in the last statement to request a voice call in which the code is read to you.>>> result = verify.verification_checks.create(to='<your phone number here>', code='123456') >>> result.status 'pending' >>> result = verify.verification_checks.create(to='<your phone number here>', code='507296') >>> result.status 'approved'
pending
. Once the correct token is given, the status changes to approved
.$ git clone https://github.com/miguelgrinberg/microblog $ cd microblog
$ git clone https://github.com/miguelgrinberg/microblog-verify $ cd microblog-verify
Config
class in *config.py* then gets three new attributes:# config.py class Config(object): # ... TWILIO_ACCOUNT_SID = os.environ.get('TWILIO_ACCOUNT_SID') TWILIO_AUTH_TOKEN = os.environ.get('TWILIO_ACCOUNT_TOKEN') TWILIO_VERIFY_SERVICE_ID = os.environ.get('TWILIO_VERIFY_SERVICE_ID')
python-dotenv
package is installed, it imports environment variables from a *.env* file, so the easiest way to get these credentials into the application is to add them to that file:# .env TWILIO_ACCOUNT_SID='<your Twilio Account SID here>' TWILIO_AUTH_TOKEN='<your Twilio Auth Token here>' TWILIO_VERIFY_SERVICE_ID='<your Twilio Verify Service SID here>'
auth
blueprint of the application, a new module *app/auth/twilio_verify.py* can be defined in this blueprint:# app/auth/twilio_verify.py from flask import current_app from twilio.rest import Client, TwilioException def _get_twilio_verify_client(): return Client( current_app.config['TWILIO_ACCOUNT_SID'], current_app.config['TWILIO_AUTH_TOKEN']).verify.services( current_app.config['TWILIO_VERIFY_SERVICE_ID']) def request_verification_token(phone): verify = _get_twilio_verify_client() try: verify.verifications.create(to=phone, channel='sms') except TwilioException: verify.verifications.create(to=phone, channel='call') def check_verification_token(phone, token): verify = _get_twilio_verify_client() try: result = verify.verification_checks.create(to=phone, code=token) except TwilioException: return False return result.status == 'approved'
_get_twilio_verify_client()
function returns a verify
instance that the two public functions can use. The request_verification_token()
function first attempts to send a verification token via SMS, and if that fails it then does a second attempt as a voice call. The check_verification_token()
function verifies a token, returning True
if the token is valid or False
if not. I hope you agree that having all this functionality encapsulated in a module helps keep the code neatly organized.User
model in file *app/models.py* can be expanded to have this attribute:# app/models.py ... class User(UserMixin, PaginatedAPIMixin, db.Model): # ... verification_phone = db.Column(db.String(16)) def two_factor_enabled(self): return self.verification_phone is not None
verification_phone
attribute, I’ve added a helper method that indicates if a user has two-factor authentication enabled or disabled. To determine the state it simply checks if the phone attribute is set or not.verification_phone
attribute:(venv) $ flask db upgrade [2019-12-06 15:18:18,130] INFO in __init__: Microblog startup INFO [alembic.runtime.migration] Context impl SQLiteImpl. INFO [alembic.runtime.migration] Will assume non-transactional DDL. INFO [alembic.runtime.migration] Running upgrade -> e517276bb1c2, users table INFO [alembic.runtime.migration] Running upgrade e517276bb1c2 -> 780739b227a7, posts table INFO [alembic.runtime.migration] Running upgrade 780739b227a7 -> 37f06a334dbf, new fields in user model INFO [alembic.runtime.migration] Running upgrade 37f06a334dbf -> ae346256b650, followers INFO [alembic.runtime.migration] Running upgrade ae346256b650 -> 2b017edaa91f, add language to posts INFO [alembic.runtime.migration] Running upgrade 2b017edaa91f -> d049de007ccf, private messages INFO [alembic.runtime.migration] Running upgrade d049de007ccf -> f7ac3d27bb1d, notifications INFO [alembic.runtime.migration] Running upgrade f7ac3d27bb1d -> c81bac34faab, tasks INFO [alembic.runtime.migration] Running upgrade c81bac34faab -> 834b1a697901, user tokens (venv) $ flask db migrate -m "two-factor authentication" [2019-12-06 15:56:05,959] INFO in __init__: Microblog startup INFO [alembic.runtime.migration] Context impl SQLiteImpl. INFO [alembic.runtime.migration] Will assume non-transactional DDL. INFO [alembic.autogenerate.compare] Detected added column 'user.verification_phone' Generating migrations/versions/aeea651280c2_two_factor_authentication.py ... done
(venv) $ flask db upgrade [2019-12-06 15:58:39,300] INFO in __init__: Microblog startup INFO [alembic.runtime.migration] Context impl SQLiteImpl. INFO [alembic.runtime.migration] Will assume non-transactional DDL. INFO [alembic.runtime.migration] Running upgrade 834b1a697901 -> aeea651280c2, two-factor authentication
{# app/templates/user.html #} {% extends "base.html" %} {% block app_content %} <table class="table table-hover"> <tr> <td width="256px"><img src="{{ user.avatar(256) }}"></td> <td> ... ... {% if not user.two_factor_enabled() %} <p><a href="{{ url_for('auth.enable_2fa') }}">{{ _('Enable two-factor authentication') }}</a></p> {% else %} <p><a href="{{ url_for('auth.disable_2fa') }}">{{ _('Disable two-factor authentication') }}</a></p> {% endif %} </td> </tr> </table> ... ...
auth
blueprint of the application, the new form can be added in *app/auth/forms.py*:# app/auth/forms.py ... class Enable2faForm(FlaskForm): verification_phone = StringField(‘Phone', validators=[DataRequired()]) submit = SubmitField('Enable 2FA') def validate_verification_phone(self, verification_phone): try: p = phonenumbers.parse(verification_phone.data) if not phonenumbers.is_valid_number(p): raise ValueError() except (phonenumbers.phonenumberutil.NumberParseException, ValueError): raise ValidationError('Invalid phone number')
phonenumbers
package. If the number is invalid then the message given in the ValidationError
exception will be displayed to the user as an error message.# app/auth.routes.py ... @bp.route('/enable_2fa', methods=['GET', 'POST']) @login_required def enable_2fa(): form = Enable2faForm() if form.validate_on_submit(): session['phone'] = form.verification_phone.data request_verification_token(session['phone']) return redirect(url_for('auth.verify_2fa')) return render_template('auth/enable_2fa.html', form=form)
GET
method for displaying the page with the form, and the POST
method to process the form submission. When the form is submitted, the phone number is stored in the user session so that it is preserved until it is verified. This is better than storing it directly to the database, because in this way, if the verification fails, or is never done, then the user account remains as it was. Then the request_verification_token()
helper function defined above is called to send a token to user’s phone.verify_2fa
.{# app/templates/auth/enable_2fa.html #} {% extends 'base.html' %} {% import 'bootstrap/wtf.html' as wtf %} {% block app_content %} <h1>Enable Two-Factor Authentication</h1> <p>Please enter your mobile number to activate two-factor authentication on your account.</p> <div class="row"> <div class="col-md-4"> {{ wtf.quick_form(form) }} </div> </div> {% endblock %} {% block styles %} {{ super() }} <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/intl-tel-input/16.0.4/css/intlTelInput.css"> {% endblock %} {% block scripts %} {{ super() }} <script src="https://cdnjs.cloudflare.com/ajax/libs/intl-tel-input/16.0.4/js/intlTelInput-jquery.min.js"></script> <script> $("#verification_phone").css({position: 'absolute', top: '-9999px', left: '-9999px'}); $("#verification_phone").parent().append('<div><input type="tel" id="_verification_phone"></div>'); $("#_verification_phone").intlTelInput({ separateDialCode: true, utilsScript: "https://cdnjs.cloudflare.com/ajax/libs/intl-tel-input/16.0.4/js/utils.js", }); $("#_verification_phone").intlTelInput("setNumber", $('#verification_phone').val()); $('#_verification_phone').blur(function() { $('#verification_phone').val($('#_verification_phone').intlTelInput("getNumber")); }); </script> {% endblock %}
app_content
block is where the actual form is rendered, and this is a fairly standard use of the Flask-Bootstrap extension. The styles
block includes the CSS file for the intl-tel-input library, imported directly from a CDN to avoid having to host the file locally, and similarly the scripts
block loads the JavaScript code for this field. In both cases the super()
statement imports other definitions for these blocks from the parent templates.quick_form()
macro provided by Flask-Bootstrap does not have the option to use custom fields, so I had to resort to some DOM tricks to replace the standard field with the phone number dropdown without breaking the form handling on the Flask side. This is all done in the second <script>
tag with a sequence of five JavaScript DOM manipulation statements:quick_form()
macro outside of the visible area of the page._verification_phone
, which is the same as the original with a _
prefix.blur
event on the new dropdown that copies the phone number back into the original phone field.login_user()
function to let the user into the system and then redirect to the page the user intended to visit originally, which Flask-Login calls the “next” page:# app/auth/routes.py ... @bp.route('/login', methods=['GET', 'POST']) def login(): # ... login_user(user, remember=form.remember_me.data) return redirect(next_page)
login_user()
call needs to be postponed, and instead this route needs to send a token to their previously stored phone number.# app/auth/routes.py ... @bp.route('/login', methods=['GET', 'POST']) def login(): # ... if user.two_factor_enabled(): request_verification_token(user.verification_phone) session['username'] = user.username session['phone'] = user.verification_phone return redirect(url_for( 'auth.verify_2fa', next=next_page, remember='1' if form.remember_me.data else '0')) login_user(user, remember=form.remember_me.data) return redirect(next_page) ...
# app/auth/forms.py ... class Confirm2faForm(FlaskForm): token = StringField('Token') submit = SubmitField('Verify')
{# app/templates/auth/verify_2fa.html #} {% extends 'base.html' %} {% import 'bootstrap/wtf.html' as wtf %} {% block app_content %} <h1>Two-Factor Authentication</h1> <p>Please enter the token that was sent to your phone.</p> <div class="row"> <div class="col-md-4"> {{ wtf.quick_form(form) }} </div> </div> {% endblock %}
# app/auth/routes.py ... @bp.route('/confirm_2fa', methods=['GET', 'POST']) def confirm_2fa(): form = Confirm2faForm() if form.validate_on_submit(): phone = session['phone'] if check_verification_token(phone, form.token.data): del session['phone'] if current_user.is_authenticated: current_user.verification_phone = phone db.session.commit() flash('Two-factor authentication is now enabled') return redirect(url_for('main.index')) else: username = session['username'] del session['username'] user = User.query.filter_by(username=username).first() next_page = request.args.get('next') remember = request.args.get('remember', '0') == '1' login_user(user, remember=remember) return redirect(next_page) form.token.errors.append('Invalid token') return render_template('auth/confirm_2fa.html', form=form)
login_user()
function using the data previously stored in the user session and the query string.# app/auth/forms.py ... class Disable2faForm(FlaskForm): submit = SubmitField('Disable 2FA')
verification_phone
attribute of the user when the form is processed:# app/auth/routes.py ... @bp.route('/disable_2fa', methods=['GET', 'POST']) @login_required def disable_2fa(): form = Disable2faForm() if form.validate_on_submit(): current_user.verification_phone = None db.session.commit() flash('Two-factor authentication is now disabled.') return redirect(url_for('main.index')) return render_template('auth/disable_2fa.html', form=form)
{# app/templates/auth/disable_2fa.html #} {% extends 'base.html' %} {% import 'bootstrap/wtf.html' as wtf %} {% block app_content %} <h1>Disable Two-Factor Authentication</h1> <p>Please click the button below to disable two-factor authentication on your account.</p> <div class="row"> <div class="col-md-4"> {{ wtf.quick_form(form) }} </div> </div> {% endblock %}
$ python3 -m venv venv $ source venv/bin/activate (venv) $ pip install -r requirements.txt (venv) $ flask db upgrade
$ python3 -m venv venv $ venv/Scripts/activate (venv) $ pip install -r requirements.txt (venv) $ flask db upgrade
TWILIO_ACCOUNT_SID='<your Twilio Account SID here>' TWILIO_AUTH_TOKEN='<your Twilio Auth Token here>' TWILIO_VERIFY_SERVICE_ID='<your Twilio Verify Service SID here>'
(venv) $ flask run