From 7bbd883955111b82b0b7299a26fade9b317d8768 Mon Sep 17 00:00:00 2001 From: manrajg13 Date: Mon, 6 Mar 2023 14:11:02 -0500 Subject: [PATCH 1/4] 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 ### From 98dae894dee08d5de814686277d5863ae82aedfd Mon Sep 17 00:00:00 2001 From: Manraj Date: Mon, 6 Mar 2023 14:36:13 -0500 Subject: [PATCH 2/4] Delete .env --- .env | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 .env diff --git a/.env b/.env deleted file mode 100644 index 094a49a..0000000 --- a/.env +++ /dev/null @@ -1,3 +0,0 @@ -TWILIO_ACCOUNT_SID='AC0bd1e642a7054c751da1cce44df0b627' -TWILIO_AUTH_TOKEN='50f07accf2e7e5888a28f2657957ddab' -TWILIO_VERIFY_SERVICE_ID='VAb34d1b5d153b89ef0706728107612a4e' \ No newline at end of file From 5352537c6df1a1d27059fdb1b7bb2ed60d9b2a16 Mon Sep 17 00:00:00 2001 From: manrajg13 Date: Wed, 8 Mar 2023 15:36:20 -0500 Subject: [PATCH 3/4] Update Dockerfile --- .gitignore | 1 + Dockerfile | 5 +++++ requirements.txt | 18 +++++++++++++----- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 1e4f836..764b603 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ nosetests.xml venv app.db microblog.log* +.env diff --git a/Dockerfile b/Dockerfile index 6604175..ebd77ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ RUN useradd microblog WORKDIR /home/microblog COPY requirements.txt requirements.txt +COPY .env .env RUN python -m venv venv RUN venv/bin/pip install -r requirements.txt RUN venv/bin/pip install gunicorn pymysql cryptography @@ -16,6 +17,10 @@ RUN chmod a+x boot.sh ENV FLASK_APP microblog.py +CMD flask db upgrade +CMD flask db migrate -m "two-factor authentication" +CMD flask db upgrade + RUN chown -R microblog:microblog ./ USER microblog diff --git a/requirements.txt b/requirements.txt index fbfd869..eb4d95f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,17 @@ alembic==1.6.5 +async-generator==1.10 +attrs==22.2.0 Babel==2.9.1 blinker==1.4 certifi==2021.5.30 chardet==4.0.0 +charset-normalizer==3.0.1 click==8.0.1 dnspython==2.1.0 dominate==2.6.0 elasticsearch==7.13.3 email-validator==1.1.3 +exceptiongroup==1.1.0 Flask==2.0.1 Flask-Babel==2.0.0 Flask-Bootstrap==3.3.7.1 @@ -19,6 +23,7 @@ Flask-Moment==1.0.1 Flask-SQLAlchemy==2.5.1 Flask-WTF==0.15.1 greenlet==2.0.2 +h11==0.14.0 httpie==2.4.0 idna==2.10 itsdangerous==2.0.1 @@ -26,6 +31,7 @@ Jinja2==3.0.1 langdetect==1.0.9 Mako==1.1.4 MarkupSafe==2.0.1 +outcome==1.2.0 Pygments==2.9.0 PyJWT==2.1.0 PySocks==1.7.1 @@ -37,14 +43,16 @@ redis==3.5.3 requests==2.25.1 requests-toolbelt==0.9.1 rq==1.9.0 -selenium +selenium==4.4.1 six==1.16.0 +sniffio==1.3.0 +sortedcontainers==2.4.0 SQLAlchemy==1.4.20 +trio==0.22.0 +trio-websocket==0.9.2 +twilio==7.16.4 urllib3==1.26.6 visitor==0.1.3 Werkzeug==2.0.1 +wsproto==1.2.0 WTForms==2.3.3 - -# requirements for Heroku -#psycopg2==2.9.1 -#gunicorn==20.1.0 From c72f13d35b794f55f28cddefcd66a6f0c34c57f0 Mon Sep 17 00:00:00 2001 From: manrajg13 Date: Fri, 10 Mar 2023 18:33:38 -0500 Subject: [PATCH 4/4] Fix: Dockerfile and routes error handling Moved copying .env in dockerfile to avoid cache invalidation. Removed try and except in confirm2fa because it did nothing. Checking if next is None or '' before returning and removed data deletions in route functions. --- Dockerfile | 2 +- app/auth/forms.py | 6 ------ app/auth/routes.py | 10 +++++----- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/Dockerfile b/Dockerfile index ebd77ad..5f4bf9c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,11 +5,11 @@ RUN useradd microblog WORKDIR /home/microblog COPY requirements.txt requirements.txt -COPY .env .env RUN python -m venv venv RUN venv/bin/pip install -r requirements.txt RUN venv/bin/pip install gunicorn pymysql cryptography +COPY .env .env COPY app app COPY migrations migrations COPY microblog.py config.py boot.sh ./ diff --git a/app/auth/forms.py b/app/auth/forms.py index f0fbba5..7ac8730 100644 --- a/app/auth/forms.py +++ b/app/auth/forms.py @@ -20,12 +20,6 @@ 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') diff --git a/app/auth/routes.py b/app/auth/routes.py index d3b5053..7762880 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -22,6 +22,8 @@ def login(): flash(_('Invalid username or password')) return redirect(url_for('auth.login')) next_page = request.args.get('next') + if not next_page or url_parse(next_page).netloc != '': + next_page = url_for('main.index') if user.two_factor_enabled(): request_verification_token(user.verification_phone) session['username'] = user.username @@ -30,19 +32,16 @@ def login(): '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']) +@bp.route('/verify_2fa', 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() @@ -50,10 +49,11 @@ def verify_2fa(): 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' + if not next_page or url_parse(next_page).netloc != '': + next_page = url_for('main.index') login_user(user, remember=remember) return redirect(next_page) form.token.errors.append('Invalid token')