diff --git a/.gitignore b/.gitignore index 1e4f836..3237ee9 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ nosetests.xml venv app.db microblog.log* +.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/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..eb2bdb1 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,45 @@ +pipeline { + agent any + + stages { + stage('Undeploy') { + steps { + // Stop running microblog container (microblog label applied to microblog container by this repo's Dockerfile) + sh 'docker stop $(docker ps -q --filter name=microblog) || true && docker rm $(docker ps -q --filter name=microblog) || true' + } + } + stage('Build') { + steps { + sh 'docker build -t microblog:latest .' + } + } + stage('Deploy') { + steps { + // Deploy new container + sh 'docker run --name microblog -d -p 5000:5000 --rm microblog:latest' + // Remove all images except for jenkins + sh 'docker image prune -af --filter "label!=org.opencontainers.image.vendor=Jenkins project"' + } + } + stage('Selenium Tests') { + steps { + echo 'Running Selenium Tests...' + //sh 'python3 selenium.py' + } + } + } + + // Post always runs even if the pipeline fails + post { + success { + echo 'Pipeline has completed' + discordSend description: '', footer: '', image: '', link: 'http://3.220.122.102:8080/', result: '', scmWebUrl: '', thumbnail: '', title: + 'Pipeline has completed ✅', webhookURL: 'https://discord.com/api/webhooks/1075879011667955872/Nk0gmKZkrISEs-hru-HjtzzgezWweABCdPsOKGIzkmj5xMcqKC3m1-dx7GZSu0yURAOo' + } + failure { + echo 'Something has failed!' + discordSend description: 'Pipeline has failed!', footer: '', image: '', link: 'http://3.220.122.102:8080/', result: '', scmWebUrl: '', thumbnail: '', title: + 'Pipeline failure ❌', webhookURL: 'https://discord.com/api/webhooks/1075879011667955872/Nk0gmKZkrISEs-hru-HjtzzgezWweABCdPsOKGIzkmj5xMcqKC3m1-dx7GZSu0yURAOo' + } + } +} diff --git a/README.md b/README.md index 82e3e48..c0567b3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,32 @@ # Welcome to Microblog! This is an example application featured in my [Flask Mega-Tutorial](https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-i-hello-world). See the tutorial for instructions on how to work with it. + +# How to run behave tutorial + +*NOTE* behave steps in the .feature file MUST unicode match those in the 'steps' file + +e.g. in the *.feature file there is a Gherkin line like +'the task 'Buy groceries' with estimate '2' should be added to the todo list' + +then in the accompanying *step.py file it must follow the exact unicode in the behave decorator +Like this -> '@then("the task '{title}' with estimate '{estimate}' should be added to the todo list")' + + +Also step files must follow the regex *step.py + +1. Running all behave features + +run " behave tests/features/add-task.feature " + +# How to run unit tests + +unit tests must follow the "test*.py" regex to be picked up + +1. Individual tests + +run " python ./ests/unit/test_example.py " + +2. Run all unit tests + +run " python -m unittest discover -s ./tests/unit " \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 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 8c51e96..f38bfb7 100644 --- a/app/models.py +++ b/app/models.py @@ -96,6 +96,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') # link archive class to user archived = db.relationship('Archive', foreign_keys='Archive.archived_by', backref='archivee', lazy='dynamic') @@ -128,6 +129,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 9f889c1..ad05f22 100644 --- a/app/templates/user.html +++ b/app/templates/user.html @@ -35,6 +35,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 bed1e03..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 @@ -18,7 +22,8 @@ Flask-Migrate==3.0.1 Flask-Moment==1.0.1 Flask-SQLAlchemy==2.5.1 Flask-WTF==0.15.1 -greenlet==2.0.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,13 +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 diff --git a/tests/features/add-task.feature b/tests/features/add-task.feature new file mode 100644 index 0000000..3e1c284 --- /dev/null +++ b/tests/features/add-task.feature @@ -0,0 +1,17 @@ +Feature: Add tasks to todo list + As a user + I want to be able to add tasks to my todo list + So thats I can keep track of my work + + Scenario: User visits the todo list page + Given the user is on the todo list page + Then the page should have a text field to enter the title of the task + And the page should have a text field to enter the estimate of hours needed to complete the task + And the page should have a button to add the task + + Scenario: Add a new task to the todo list + Given I am on the todo list page + When I enter 'Buy groceries' in the title field + And I enter '2' in the estimate field + And I click the 'Add' button + Then the task 'Buy groceries' with estimate '2' should be added to the todo list \ No newline at end of file diff --git a/tests/features/steps/add-task-steps.py b/tests/features/steps/add-task-steps.py new file mode 100644 index 0000000..4fa5cf2 --- /dev/null +++ b/tests/features/steps/add-task-steps.py @@ -0,0 +1,54 @@ +from behave import given, when, then +from webdriver_manager.chrome import ChromeDriverManager +from selenium import webdriver +from selenium.webdriver.chrome.service import Service as ChromeService +from selenium.webdriver.common.by import By +from selenium.webdriver.chrome.options import Options + +#THESE ARE EXAMPLES FILES. +#Todo delete these once we set a standard with our own tests + +@given("I am on the todo list page") +def open_browser(context): + + # Implementation of headless from https://stackoverflow.com/questions/46920243/how-to-configure-chromedriver-to-initiate-chrome-browser-in-headless-mode-throug + # Stackoverflow post desribes what is goin on with options to enable headless chrome + + options = Options() + options.add_argument("--headless") # Runs Chrome in headless mode. + options.add_argument('--no-sandbox') # Bypass OS security model + options.add_argument('start-maximized') #to maximize viewport this should still be headless + options.add_argument('disable-infobars') + options.add_argument("--disable-extensions") + context.driver = webdriver.Chrome(options=options, service=ChromeService(ChromeDriverManager().install())) + context.driver.implicitly_wait(5) + context.driver.get("http://127.0.0.1:5000/") + + +@when("I enter '{title}' in the title field") +def step_impl(context, title): + title_field = context.driver.find_element(By.NAME, "title") + title_field.send_keys(title) + + +@when("I enter '{estimate}' in the estimate field") +def step_impl(context, estimate): + estimate_field = context.driver.find_element(By.NAME, "estimate") + estimate_field.send_keys(estimate) + + +@when("I click the 'Add' button") +def step_impl(context): + add_button = context.driver.find_element(By.XPATH, "//button[contains(text(),'Add')]") + add_button.click() + context.driver.implicitly_wait(5) + + +@then("the task '{title}' with estimate '{estimate}' should be added to the todo list") +def step_impl(context, title, estimate): + dump_text = context.driver.page_source + print(dump_text) + assert ("Buy groceries | 2" in dump_text) is True + + + diff --git a/tests/features/steps/check-ui-steps.py b/tests/features/steps/check-ui-steps.py new file mode 100644 index 0000000..1f07681 --- /dev/null +++ b/tests/features/steps/check-ui-steps.py @@ -0,0 +1,39 @@ +from behave import given, when, then +from webdriver_manager.chrome import ChromeDriverManager +from selenium import webdriver +from selenium.webdriver.chrome.service import Service as ChromeService +from selenium.webdriver.common.by import By +from selenium.webdriver.chrome.options import Options + +#THESE ARE EXAMPLES FILES. +#Todo delete these once we set a standard with our own tests + +@given("the user is on the todo list page") +def open_browser(context): + options = Options() + options.add_argument("--headless") # Runs Chrome in headless mode. + options.add_argument('--no-sandbox') # Bypass OS security model + options.add_argument('start-maximized') #to maximize viewport this should still be headless + options.add_argument('disable-infobars') + options.add_argument("--disable-extensions") + context.driver = webdriver.Chrome(options=options, service=ChromeService(ChromeDriverManager().install())) + context.driver.implicitly_wait(5) + context.driver.get("http://127.0.0.1:5000/") + + +@then("the page should have a text field to enter the title of the task") +def check_task_title_textbox(context): + status = context.driver.find_element(By.NAME, "title").is_displayed() + assert status is True + + +@then("the page should have a text field to enter the estimate of hours needed to complete the task") +def check_task_estimate_textbox(context): + status = context.driver.find_element(By.NAME, "estimate").is_displayed() + assert status is True + + +@then("the page should have a button to add the task") +def check_task_add_button(context): + status = context.driver.find_element(By.XPATH, "//button[contains(text(),'Add')]").is_displayed() + assert status is True diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests.py b/tests/unit/test_example.py old mode 100755 new mode 100644 similarity index 99% rename from tests.py rename to tests/unit/test_example.py index 52111b1..fb9827f --- a/tests.py +++ b/tests/unit/test_example.py @@ -1,4 +1,7 @@ #!/usr/bin/env python +import sys +sys.path.append('./') + from datetime import datetime, timedelta import unittest from app import create_app, db