diff --git a/app/auth/templates/auth/fido.html b/app/auth/templates/auth/fido.html index b2d2ea75..63be1c13 100644 --- a/app/auth/templates/auth/fido.html +++ b/app/auth/templates/auth/fido.html @@ -26,6 +26,13 @@
+ +
+ {{ fido_token_form.remember(class="form-check-input", id="remember") }} + +
diff --git a/app/auth/templates/auth/login.html b/app/auth/templates/auth/login.html index 3d51062f..6f60139d 100644 --- a/app/auth/templates/auth/login.html +++ b/app/auth/templates/auth/login.html @@ -20,7 +20,7 @@
Welcome back!
- {{ form.email(class="form-control", type="email") }} + {{ form.email(class="form-control", type="email", autofocus="true") }} {{ render_field_errors(form.email) }}
diff --git a/app/auth/templates/auth/mfa.html b/app/auth/templates/auth/mfa.html index 064a8598..07a17d5c 100644 --- a/app/auth/templates/auth/mfa.html +++ b/app/auth/templates/auth/mfa.html @@ -26,6 +26,12 @@ {{ otp_token_form.token(class="form-control", autofocus="true") }} {{ render_field_errors(otp_token_form.token) }} +
+ {{ otp_token_form.remember(class="form-check-input", id="remember") }} + +
diff --git a/app/auth/views/fido.py b/app/auth/views/fido.py index 71c1f429..43fa4818 100644 --- a/app/auth/views/fido.py +++ b/app/auth/views/fido.py @@ -1,22 +1,32 @@ import json import secrets - import webauthn -from flask import request, render_template, redirect, url_for, flash, session +from flask import ( + request, + render_template, + redirect, + url_for, + flash, + session, + make_response, +) from flask_login import login_user from flask_wtf import FlaskForm -from wtforms import HiddenField, validators +from wtforms import HiddenField, validators, BooleanField from app.auth.base import auth_bp from app.config import MFA_USER_ID from app.config import RP_ID, URL from app.extensions import db from app.log import LOG -from app.models import User, Fido +from app.models import User, Fido, MfaBrowser class FidoTokenForm(FlaskForm): sk_assertion = HiddenField("sk_assertion", validators=[validators.DataRequired()]) + remember = BooleanField( + "attr", default=False, description="Remember this browser for 30 days" + ) @auth_bp.route("/fido", methods=["GET", "POST"]) @@ -40,6 +50,14 @@ def fido(): next_url = request.args.get("next") + if request.cookies.get("mfa"): + browser = MfaBrowser.get_by(token=request.cookies.get("mfa")) + if browser and not browser.is_expired() and browser.user_id == user.id: + login_user(user) + flash(f"Welcome back {user.name}!", "success") + # Redirect user to correct page + return redirect(next_url or url_for("dashboard.index")) + # Handling POST requests if fido_token_form.validate_on_submit(): try: @@ -80,13 +98,22 @@ def fido(): login_user(user) flash(f"Welcome back {user.name}!", "success") - # User comes to login page from another page - if next_url: - LOG.debug("redirect user to %s", next_url) - return redirect(next_url) - else: - LOG.debug("redirect user to dashboard") - return redirect(url_for("dashboard.index")) + # Redirect user to correct page + response = make_response(redirect(next_url or url_for("dashboard.index"))) + + if fido_token_form.remember.data: + browser = MfaBrowser.create_new(user=user) + db.session.commit() + response.set_cookie( + "mfa", + value=browser.token, + expires=browser.expires.datetime, + secure=True if URL.startswith("https") else False, + httponly=True, + samesite="Lax", + ) + + return response # Prepare information for key registration process session.pop("challenge", None) diff --git a/app/auth/views/logout.py b/app/auth/views/logout.py index d6b2f559..cad96694 100644 --- a/app/auth/views/logout.py +++ b/app/auth/views/logout.py @@ -1,4 +1,4 @@ -from flask import redirect, url_for, flash +from flask import redirect, url_for, flash, make_response from flask_login import logout_user from app.auth.base import auth_bp @@ -8,4 +8,9 @@ from app.auth.base import auth_bp def logout(): logout_user() flash("You are logged out", "success") - return redirect(url_for("auth.login")) + response = make_response(redirect(url_for("auth.login"))) + response.delete_cookie("slapp") + response.delete_cookie("mfa") + response.delete_cookie("dark-mode") + + return response diff --git a/app/auth/views/mfa.py b/app/auth/views/mfa.py index b7d68137..0a6bfc72 100644 --- a/app/auth/views/mfa.py +++ b/app/auth/views/mfa.py @@ -1,17 +1,28 @@ import pyotp -from flask import request, render_template, redirect, url_for, flash, session +from flask import ( + render_template, + redirect, + url_for, + flash, + session, + make_response, + request, +) from flask_login import login_user from flask_wtf import FlaskForm -from wtforms import StringField, validators +from wtforms import BooleanField, StringField, validators from app.auth.base import auth_bp -from app.config import MFA_USER_ID -from app.log import LOG -from app.models import User +from app.config import MFA_USER_ID, URL +from app.extensions import db +from app.models import User, MfaBrowser class OtpTokenForm(FlaskForm): token = StringField("Token", validators=[validators.DataRequired()]) + remember = BooleanField( + "attr", default=False, description="Remember this browser for 30 days" + ) @auth_bp.route("/mfa", methods=["GET", "POST"]) @@ -33,27 +44,47 @@ def mfa(): otp_token_form = OtpTokenForm() next_url = request.args.get("next") + if request.cookies.get("mfa"): + browser = MfaBrowser.get_by(token=request.cookies.get("mfa")) + if browser and not browser.is_expired() and browser.user_id == user.id: + login_user(user) + flash(f"Welcome back {user.name}!", "success") + # Redirect user to correct page + return redirect(next_url or url_for("dashboard.index")) + if otp_token_form.validate_on_submit(): totp = pyotp.TOTP(user.otp_secret) - token = otp_token_form.token.data + token = otp_token_form.token.data.replace(" ", "") - if totp.verify(token): + if totp.verify(token) and user.last_otp != token: del session[MFA_USER_ID] + user.last_otp = token + db.session.commit() login_user(user) - flash(f"Welcome back {user.name}!") + flash(f"Welcome back {user.name}!", "success") - # User comes to login page from another page - if next_url: - LOG.debug("redirect user to %s", next_url) - return redirect(next_url) - else: - LOG.debug("redirect user to dashboard") - return redirect(url_for("dashboard.index")) + # Redirect user to correct page + response = make_response(redirect(next_url or url_for("dashboard.index"))) + + if otp_token_form.remember.data: + browser = MfaBrowser.create_new(user=user) + db.session.commit() + response.set_cookie( + "mfa", + value=browser.token, + expires=browser.expires.datetime, + secure=True if URL.startswith("https") else False, + httponly=True, + samesite="Lax", + ) + + return response else: flash("Incorrect token", "warning") + otp_token_form.token.data = None return render_template( "auth/mfa.html", diff --git a/app/dashboard/views/mfa_setup.py b/app/dashboard/views/mfa_setup.py index 2b7ac48c..afb639e4 100644 --- a/app/dashboard/views/mfa_setup.py +++ b/app/dashboard/views/mfa_setup.py @@ -32,10 +32,11 @@ def mfa_setup(): totp = pyotp.TOTP(current_user.otp_secret) if otp_token_form.validate_on_submit(): - token = otp_token_form.token.data + token = otp_token_form.token.data.replace(" ", "") - if totp.verify(token): + if totp.verify(token) and current_user.last_otp != token: current_user.enable_otp = True + current_user.last_otp = token db.session.commit() flash("MFA has been activated", "success") diff --git a/app/models.py b/app/models.py index 300776ad..9e3ef46f 100644 --- a/app/models.py +++ b/app/models.py @@ -161,6 +161,7 @@ class User(db.Model, ModelMixin, UserMixin): enable_otp = db.Column( db.Boolean, nullable=False, default=False, server_default="0" ) + last_otp = db.Column(db.String(12), nullable=True, default=False) # Fields for WebAuthn fido_uuid = db.Column(db.String(), nullable=True, unique=True) @@ -508,6 +509,43 @@ def generate_oauth_client_id(client_name) -> str: return generate_oauth_client_id(client_name) +class MfaBrowser(db.Model, ModelMixin): + user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False) + token = db.Column(db.String(64), default=False, unique=True, nullable=False) + expires = db.Column(ArrowType, default=False, nullable=False) + + user = db.relationship(User) + + @classmethod + def create_new(cls, user, token_length=64) -> "MfaBrowser": + found = False + while not found: + token = random_string(token_length) + + if not cls.get_by(token=token): + found = True + + return MfaBrowser.create( + user_id=user.id, token=token, expires=arrow.now().shift(days=30), + ) + + @classmethod + def delete(cls, token): + cls.query.filter(cls.token == token).delete() + db.session.commit() + + @classmethod + def delete_expired(cls): + cls.query.filter(cls.expires < arrow.now()).delete() + db.session.commit() + + def is_expired(self): + return self.expires < arrow.now() + + def reset_expire(self): + self.expires = arrow.now().shift(days=30) + + class Client(db.Model, ModelMixin): oauth_client_id = db.Column(db.String(128), unique=True, nullable=False) oauth_client_secret = db.Column(db.String(128), nullable=False) diff --git a/migrations/versions/2020_052216_95599239860a_.py b/migrations/versions/2020_052216_95599239860a_.py new file mode 100644 index 00000000..a8321f2f --- /dev/null +++ b/migrations/versions/2020_052216_95599239860a_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: 95599239860a +Revises: ce15cf3467b4 +Create Date: 2020-05-22 16:14:33.704035 + +""" +import sqlalchemy_utils +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '95599239860a' +down_revision = 'ce15cf3467b4' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('mfa_browser', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False), + sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('token', sa.String(length=64), nullable=False), + sa.Column('expires', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('mfa_browser') + # ### end Alembic commands ### diff --git a/migrations/versions/2020_052216_ea50319ea811_.py b/migrations/versions/2020_052216_ea50319ea811_.py new file mode 100644 index 00000000..faad2228 --- /dev/null +++ b/migrations/versions/2020_052216_ea50319ea811_.py @@ -0,0 +1,29 @@ +"""empty message + +Revision ID: ea50319ea811 +Revises: 95599239860a +Create Date: 2020-05-22 16:49:25.613344 + +""" +import sqlalchemy_utils +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ea50319ea811' +down_revision = '95599239860a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('last_otp', sa.String(length=12), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'last_otp') + # ### end Alembic commands ###