diff --git a/.gitignore b/.gitignore index 3d8d8c7..3237ee9 100644 --- a/.gitignore +++ b/.gitignore @@ -38,5 +38,5 @@ nosetests.xml venv app.db microblog.log* - -.idea/ +.env +.idea/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 6604175..5f4bf9c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ 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 ./ @@ -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/app/auth/forms.py b/app/auth/forms.py index c1dd3eb..7ac8730 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,20 @@ 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') + + +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..7762880 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 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 + 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) return render_template('auth/login.html', title=_('Sign In'), form=form) +@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): + 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'] + 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') + 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 ### diff --git a/requirements.txt b/requirements.txt index c9f1ec1..3269b02 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,16 +43,19 @@ redis==3.5.3 requests==2.25.1 requests-toolbelt==0.9.1 rq==1.9.0 +selenium==4.4.1 selenium behave webdriver_manager 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