mirror of
https://github.com/simple-login/app.git
synced 2025-10-04 12:24:47 +08:00
Merge pull request #203 from SibrenVasse/remember_mfa
Optional 'remember MFA' for browser
This commit is contained in:
commit
93b7ff3d28
10 changed files with 213 additions and 31 deletions
|
@ -26,6 +26,13 @@
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<button id="btnVerifyKey" class="btn btn-success mt-2" onclick="verifyKey();">Use your security key</button>
|
<button id="btnVerifyKey" class="btn btn-success mt-2" onclick="verifyKey();">Use your security key</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
{{ fido_token_form.remember(class="form-check-input", id="remember") }}
|
||||||
|
<label class="form-check-label" for="remember">
|
||||||
|
{{ fido_token_form.remember.description }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@
|
||||||
<div class="card-title">Welcome back!</div>
|
<div class="card-title">Welcome back!</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Email address</label>
|
<label class="form-label">Email address</label>
|
||||||
{{ form.email(class="form-control", type="email") }}
|
{{ form.email(class="form-control", type="email", autofocus="true") }}
|
||||||
{{ render_field_errors(form.email) }}
|
{{ render_field_errors(form.email) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -26,6 +26,12 @@
|
||||||
|
|
||||||
{{ otp_token_form.token(class="form-control", autofocus="true") }}
|
{{ otp_token_form.token(class="form-control", autofocus="true") }}
|
||||||
{{ render_field_errors(otp_token_form.token) }}
|
{{ render_field_errors(otp_token_form.token) }}
|
||||||
|
<div class="form-check">
|
||||||
|
{{ otp_token_form.remember(class="form-check-input", id="remember") }}
|
||||||
|
<label class="form-check-label" for="remember">
|
||||||
|
{{ otp_token_form.remember.description }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<button class="btn btn-success mt-2">Validate</button>
|
<button class="btn btn-success mt-2">Validate</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
|
@ -1,22 +1,32 @@
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
import webauthn
|
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_login import login_user
|
||||||
from flask_wtf import FlaskForm
|
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.auth.base import auth_bp
|
||||||
from app.config import MFA_USER_ID
|
from app.config import MFA_USER_ID
|
||||||
from app.config import RP_ID, URL
|
from app.config import RP_ID, URL
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
from app.models import User, Fido
|
from app.models import User, Fido, MfaBrowser
|
||||||
|
|
||||||
|
|
||||||
class FidoTokenForm(FlaskForm):
|
class FidoTokenForm(FlaskForm):
|
||||||
sk_assertion = HiddenField("sk_assertion", validators=[validators.DataRequired()])
|
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"])
|
@auth_bp.route("/fido", methods=["GET", "POST"])
|
||||||
|
@ -40,6 +50,14 @@ def fido():
|
||||||
|
|
||||||
next_url = request.args.get("next")
|
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
|
# Handling POST requests
|
||||||
if fido_token_form.validate_on_submit():
|
if fido_token_form.validate_on_submit():
|
||||||
try:
|
try:
|
||||||
|
@ -80,13 +98,22 @@ def fido():
|
||||||
login_user(user)
|
login_user(user)
|
||||||
flash(f"Welcome back {user.name}!", "success")
|
flash(f"Welcome back {user.name}!", "success")
|
||||||
|
|
||||||
# User comes to login page from another page
|
# Redirect user to correct page
|
||||||
if next_url:
|
response = make_response(redirect(next_url or url_for("dashboard.index")))
|
||||||
LOG.debug("redirect user to %s", next_url)
|
|
||||||
return redirect(next_url)
|
if fido_token_form.remember.data:
|
||||||
else:
|
browser = MfaBrowser.create_new(user=user)
|
||||||
LOG.debug("redirect user to dashboard")
|
db.session.commit()
|
||||||
return redirect(url_for("dashboard.index"))
|
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
|
# Prepare information for key registration process
|
||||||
session.pop("challenge", None)
|
session.pop("challenge", None)
|
||||||
|
|
|
@ -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 flask_login import logout_user
|
||||||
|
|
||||||
from app.auth.base import auth_bp
|
from app.auth.base import auth_bp
|
||||||
|
@ -8,4 +8,9 @@ from app.auth.base import auth_bp
|
||||||
def logout():
|
def logout():
|
||||||
logout_user()
|
logout_user()
|
||||||
flash("You are logged out", "success")
|
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
|
||||||
|
|
|
@ -1,17 +1,28 @@
|
||||||
import pyotp
|
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_login import login_user
|
||||||
from flask_wtf import FlaskForm
|
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.auth.base import auth_bp
|
||||||
from app.config import MFA_USER_ID
|
from app.config import MFA_USER_ID, URL
|
||||||
from app.log import LOG
|
from app.extensions import db
|
||||||
from app.models import User
|
from app.models import User, MfaBrowser
|
||||||
|
|
||||||
|
|
||||||
class OtpTokenForm(FlaskForm):
|
class OtpTokenForm(FlaskForm):
|
||||||
token = StringField("Token", validators=[validators.DataRequired()])
|
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"])
|
@auth_bp.route("/mfa", methods=["GET", "POST"])
|
||||||
|
@ -33,27 +44,47 @@ def mfa():
|
||||||
otp_token_form = OtpTokenForm()
|
otp_token_form = OtpTokenForm()
|
||||||
next_url = request.args.get("next")
|
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():
|
if otp_token_form.validate_on_submit():
|
||||||
totp = pyotp.TOTP(user.otp_secret)
|
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]
|
del session[MFA_USER_ID]
|
||||||
|
user.last_otp = token
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
login_user(user)
|
login_user(user)
|
||||||
flash(f"Welcome back {user.name}!")
|
flash(f"Welcome back {user.name}!", "success")
|
||||||
|
|
||||||
# User comes to login page from another page
|
# Redirect user to correct page
|
||||||
if next_url:
|
response = make_response(redirect(next_url or url_for("dashboard.index")))
|
||||||
LOG.debug("redirect user to %s", next_url)
|
|
||||||
return redirect(next_url)
|
if otp_token_form.remember.data:
|
||||||
else:
|
browser = MfaBrowser.create_new(user=user)
|
||||||
LOG.debug("redirect user to dashboard")
|
db.session.commit()
|
||||||
return redirect(url_for("dashboard.index"))
|
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:
|
else:
|
||||||
flash("Incorrect token", "warning")
|
flash("Incorrect token", "warning")
|
||||||
|
otp_token_form.token.data = None
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"auth/mfa.html",
|
"auth/mfa.html",
|
||||||
|
|
|
@ -32,10 +32,11 @@ def mfa_setup():
|
||||||
totp = pyotp.TOTP(current_user.otp_secret)
|
totp = pyotp.TOTP(current_user.otp_secret)
|
||||||
|
|
||||||
if otp_token_form.validate_on_submit():
|
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.enable_otp = True
|
||||||
|
current_user.last_otp = token
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("MFA has been activated", "success")
|
flash("MFA has been activated", "success")
|
||||||
|
|
||||||
|
|
|
@ -161,6 +161,7 @@ class User(db.Model, ModelMixin, UserMixin):
|
||||||
enable_otp = db.Column(
|
enable_otp = db.Column(
|
||||||
db.Boolean, nullable=False, default=False, server_default="0"
|
db.Boolean, nullable=False, default=False, server_default="0"
|
||||||
)
|
)
|
||||||
|
last_otp = db.Column(db.String(12), nullable=True, default=False)
|
||||||
|
|
||||||
# Fields for WebAuthn
|
# Fields for WebAuthn
|
||||||
fido_uuid = db.Column(db.String(), nullable=True, unique=True)
|
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)
|
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):
|
class Client(db.Model, ModelMixin):
|
||||||
oauth_client_id = db.Column(db.String(128), unique=True, nullable=False)
|
oauth_client_id = db.Column(db.String(128), unique=True, nullable=False)
|
||||||
oauth_client_secret = db.Column(db.String(128), nullable=False)
|
oauth_client_secret = db.Column(db.String(128), nullable=False)
|
||||||
|
|
38
migrations/versions/2020_052216_95599239860a_.py
Normal file
38
migrations/versions/2020_052216_95599239860a_.py
Normal file
|
@ -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 ###
|
29
migrations/versions/2020_052216_ea50319ea811_.py
Normal file
29
migrations/versions/2020_052216_ea50319ea811_.py
Normal file
|
@ -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 ###
|
Loading…
Add table
Reference in a new issue