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 ###