From 7bbd883955111b82b0b7299a26fade9b317d8768 Mon Sep 17 00:00:00 2001 From: manrajg13 Date: Mon, 6 Mar 2023 14:11:02 -0500 Subject: [PATCH] Add 2FA --- .env | 3 + app/auth/forms.py | 21 ++++++- app/auth/routes.py | 63 +++++++++++++++++-- app/auth/twilio_verify.py | 26 ++++++++ app/models.py | 4 ++ app/templates/auth/disable_2fa.html | 13 ++++ app/templates/auth/enable_2fa.html | 34 ++++++++++ app/templates/auth/verify_2fa.html | 12 ++++ app/templates/user.html | 5 ++ config.py | 3 + .../23e9d8ca48c2_two_factor_authentication.py | 28 +++++++++ 11 files changed, 207 insertions(+), 5 deletions(-) create mode 100644 .env create mode 100644 app/auth/twilio_verify.py create mode 100644 app/templates/auth/disable_2fa.html create mode 100644 app/templates/auth/enable_2fa.html create mode 100644 app/templates/auth/verify_2fa.html create mode 100644 migrations/versions/23e9d8ca48c2_two_factor_authentication.py diff --git a/.env b/.env new file mode 100644 index 0000000..094a49a --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +TWILIO_ACCOUNT_SID='AC0bd1e642a7054c751da1cce44df0b627' +TWILIO_AUTH_TOKEN='50f07accf2e7e5888a28f2657957ddab' +TWILIO_VERIFY_SERVICE_ID='VAb34d1b5d153b89ef0706728107612a4e' \ No newline at end of file diff --git a/app/auth/forms.py b/app/auth/forms.py index c1dd3eb..f0fbba5 100644 --- a/app/auth/forms.py +++ b/app/auth/forms.py @@ -4,7 +4,6 @@ from wtforms.validators import ValidationError, DataRequired, Email, EqualTo from flask_babel import _, lazy_gettext as _l from app.models import User - class LoginForm(FlaskForm): username = StringField(_l('Username'), validators=[DataRequired()]) password = PasswordField(_l('Password'), validators=[DataRequired()]) @@ -12,6 +11,26 @@ class LoginForm(FlaskForm): submit = SubmitField(_l('Sign In')) +class Confirm2faForm(FlaskForm): + token = StringField('Token') + submit = SubmitField('Verify') + + +class Enable2faForm(FlaskForm): + verification_phone = StringField('Phone', validators=[DataRequired()]) + submit = SubmitField('Enable 2FA') + + def validate_verification_phone(self, verification_phone): + try: + return + except: + print("An exception occurred") + + +class Disable2faForm(FlaskForm): + submit = SubmitField('Disable 2FA') + + class RegistrationForm(FlaskForm): username = StringField(_l('Username'), validators=[DataRequired()]) email = StringField(_l('Email'), validators=[DataRequired(), Email()]) diff --git a/app/auth/routes.py b/app/auth/routes.py index b3f1d72..d3b5053 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -1,10 +1,11 @@ -from flask import render_template, redirect, url_for, flash, request +from app.auth.twilio_verify import check_verification_token, request_verification_token +from flask import render_template, redirect, url_for, flash, request, session from werkzeug.urls import url_parse -from flask_login import login_user, logout_user, current_user +from flask_login import login_user, login_required, logout_user, current_user from flask_babel import _ from app import db from app.auth import bp -from app.auth.forms import LoginForm, RegistrationForm, \ +from app.auth.forms import Confirm2faForm, Disable2faForm, Enable2faForm, LoginForm, RegistrationForm, \ ResetPasswordRequestForm, ResetPasswordForm from app.models import User from app.auth.email import send_password_reset_email @@ -20,14 +21,68 @@ def login(): if user is None or not user.check_password(form.password.data): flash(_('Invalid username or password')) return redirect(url_for('auth.login')) - login_user(user, remember=form.remember_me.data) next_page = request.args.get('next') + 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) if not next_page or url_parse(next_page).netloc != '': next_page = url_for('main.index') return redirect(next_page) return render_template('auth/login.html', title=_('Sign In'), form=form) +@bp.route('/verify2fa', methods=['GET', 'POST']) +def verify_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/verify_2fa.html', form=form) + + +@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) + + +@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) + + @bp.route('/logout') def logout(): logout_user() diff --git a/app/auth/twilio_verify.py b/app/auth/twilio_verify.py new file mode 100644 index 0000000..97b219b --- /dev/null +++ b/app/auth/twilio_verify.py @@ -0,0 +1,26 @@ +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' \ No newline at end of file diff --git a/app/models.py b/app/models.py index 5bdab20..8ae4a82 100644 --- a/app/models.py +++ b/app/models.py @@ -94,6 +94,7 @@ class User(UserMixin, PaginatedAPIMixin, db.Model): username = db.Column(db.String(64), index=True, unique=True) email = db.Column(db.String(120), index=True, unique=True) password_hash = db.Column(db.String(128)) + verification_phone = db.Column(db.String(16)) posts = db.relationship('Post', backref='author', lazy='dynamic') about_me = db.Column(db.String(140)) last_seen = db.Column(db.DateTime, default=datetime.utcnow) @@ -123,6 +124,9 @@ class User(UserMixin, PaginatedAPIMixin, db.Model): def check_password(self, password): return check_password_hash(self.password_hash, password) + + def two_factor_enabled(self): + return self.verification_phone is not None def avatar(self, size): digest = md5(self.email.lower().encode('utf-8')).hexdigest() diff --git a/app/templates/auth/disable_2fa.html b/app/templates/auth/disable_2fa.html new file mode 100644 index 0000000..c65e110 --- /dev/null +++ b/app/templates/auth/disable_2fa.html @@ -0,0 +1,13 @@ +{# app/templates/auth/disable_2fa.html #} +{% extends 'base.html' %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block app_content %} +

Disable Two-Factor Authentication

+

Please click the button below to disable two-factor authentication on your account.

+
+
+ {{ wtf.quick_form(form) }} +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/auth/enable_2fa.html b/app/templates/auth/enable_2fa.html new file mode 100644 index 0000000..a8deb3e --- /dev/null +++ b/app/templates/auth/enable_2fa.html @@ -0,0 +1,34 @@ +{% extends 'base.html' %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block app_content %} +

Enable Two-Factor Authentication

+

Please enter your mobile number to activate two-factor authentication on your account.

+
+
+ {{ wtf.quick_form(form) }} +
+
+{% endblock %} + +{% block styles %} + {{ super() }} + +{% endblock %} + +{% block scripts %} + {{ super() }} + + +{% endblock %} diff --git a/app/templates/auth/verify_2fa.html b/app/templates/auth/verify_2fa.html new file mode 100644 index 0000000..93028ee --- /dev/null +++ b/app/templates/auth/verify_2fa.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block app_content %} +

Two-Factor Authentication

+

Please enter the token that was sent to your phone.

+
+
+ {{ wtf.quick_form(form) }} +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/user.html b/app/templates/user.html index 1d85872..ed71258 100644 --- a/app/templates/user.html +++ b/app/templates/user.html @@ -34,6 +34,11 @@ {% if user != current_user %}

{{ _('Send private message') }}

{% endif %} + {% if not user.two_factor_enabled() %} +

{{ _('Enable two-factor authentication') }}

+ {% else %} +

{{ _('Disable two-factor authentication') }}

+ {% endif %} diff --git a/config.py b/config.py index 481fa1a..8e4f51c 100644 --- a/config.py +++ b/config.py @@ -11,6 +11,9 @@ class Config(object): 'postgres://', 'postgresql://') or \ 'sqlite:///' + os.path.join(basedir, 'app.db') SQLALCHEMY_TRACK_MODIFICATIONS = False + 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') LOG_TO_STDOUT = os.environ.get('LOG_TO_STDOUT') MAIL_SERVER = os.environ.get('MAIL_SERVER') MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25) diff --git a/migrations/versions/23e9d8ca48c2_two_factor_authentication.py b/migrations/versions/23e9d8ca48c2_two_factor_authentication.py new file mode 100644 index 0000000..285010d --- /dev/null +++ b/migrations/versions/23e9d8ca48c2_two_factor_authentication.py @@ -0,0 +1,28 @@ +"""two-factor authentication + +Revision ID: 23e9d8ca48c2 +Revises: 834b1a697901 +Create Date: 2023-03-04 14:26:06.269415 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '23e9d8ca48c2' +down_revision = '834b1a697901' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('verification_phone', sa.String(length=16), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'verification_phone') + # ### end Alembic commands ###