Display recovery codes for mfa only once (#1317)
* Recovery codes can only be shown after adding a 2FA code and cannot be seen afterwards * Added recovery codes fix * Updated models and script * Formatting * Format * Added base code * Updated wording * Set the config by default Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
This commit is contained in:
parent
faaff7e9b9
commit
faeddc365c
|
@ -42,7 +42,7 @@ def recovery_route():
|
|||
|
||||
if recovery_form.validate_on_submit():
|
||||
code = recovery_form.code.data
|
||||
recovery_code = RecoveryCode.get_by(user_id=user.id, code=code)
|
||||
recovery_code = RecoveryCode.find_by_user_code(user, code)
|
||||
|
||||
if recovery_code:
|
||||
if recovery_code.used:
|
||||
|
|
|
@ -494,3 +494,12 @@ JOB_TAKEN_RETRY_WAIT_MINS = 30
|
|||
|
||||
# MEM_STORE
|
||||
MEM_STORE_URI = os.environ.get("MEM_STORE_URI", None)
|
||||
|
||||
# Recovery codes hash salt
|
||||
RECOVERY_CODE_HMAC_SECRET = os.environ.get("RECOVERY_CODE_HMAC_SECRET") or (
|
||||
FLASK_SECRET + "generatearandomtoken"
|
||||
)
|
||||
if not RECOVERY_CODE_HMAC_SECRET or len(RECOVERY_CODE_HMAC_SECRET) < 16:
|
||||
raise RuntimeError(
|
||||
"Please define RECOVERY_CODE_HMAC_SECRET in your configuration with a random string at least 16 chars long"
|
||||
)
|
||||
|
|
|
@ -23,7 +23,6 @@ from .views import (
|
|||
mailbox_detail,
|
||||
refused_email,
|
||||
referral,
|
||||
recovery_code,
|
||||
contact_detail,
|
||||
setup_done,
|
||||
batch_import,
|
||||
|
|
|
@ -78,10 +78,10 @@ def fido_setup():
|
|||
)
|
||||
|
||||
flash("Security key has been activated", "success")
|
||||
if not RecoveryCode.filter_by(user_id=current_user.id).all():
|
||||
return redirect(url_for("dashboard.recovery_code_route"))
|
||||
else:
|
||||
return redirect(url_for("dashboard.fido_manage"))
|
||||
recovery_codes = RecoveryCode.generate(current_user)
|
||||
return render_template(
|
||||
"dashboard/recovery_code.html", recovery_codes=recovery_codes
|
||||
)
|
||||
|
||||
# Prepare information for key registration process
|
||||
fido_uuid = (
|
||||
|
|
|
@ -8,6 +8,7 @@ from app.dashboard.base import dashboard_bp
|
|||
from app.dashboard.views.enter_sudo import sudo_required
|
||||
from app.db import Session
|
||||
from app.log import LOG
|
||||
from app.models import RecoveryCode
|
||||
|
||||
|
||||
class OtpTokenForm(FlaskForm):
|
||||
|
@ -39,8 +40,10 @@ def mfa_setup():
|
|||
current_user.last_otp = token
|
||||
Session.commit()
|
||||
flash("MFA has been activated", "success")
|
||||
|
||||
return redirect(url_for("dashboard.recovery_code_route"))
|
||||
recovery_codes = RecoveryCode.generate(current_user)
|
||||
return render_template(
|
||||
"dashboard/recovery_code.html", recovery_codes=recovery_codes
|
||||
)
|
||||
else:
|
||||
flash("Incorrect token", "warning")
|
||||
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
from flask import render_template, flash, redirect, url_for, request
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.log import LOG
|
||||
from app.models import RecoveryCode
|
||||
|
||||
|
||||
@dashboard_bp.route("/recovery_code", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def recovery_code_route():
|
||||
if not current_user.two_factor_authentication_enabled():
|
||||
flash("you need to enable either TOTP or WebAuthn", "warning")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
recovery_codes = RecoveryCode.filter_by(user_id=current_user.id).all()
|
||||
if request.method == "GET" and not recovery_codes:
|
||||
# user arrives at this page for the first time
|
||||
LOG.d("%s has no recovery keys, generate", current_user)
|
||||
RecoveryCode.generate(current_user)
|
||||
recovery_codes = RecoveryCode.filter_by(user_id=current_user.id).all()
|
||||
|
||||
if request.method == "POST":
|
||||
RecoveryCode.generate(current_user)
|
||||
flash("New recovery codes generated", "success")
|
||||
return redirect(url_for("dashboard.recovery_code_route"))
|
||||
|
||||
return render_template(
|
||||
"dashboard/recovery_code.html", recovery_codes=recovery_codes
|
||||
)
|
|
@ -2683,12 +2683,21 @@ class RecoveryCode(Base, ModelMixin):
|
|||
__table_args__ = (sa.UniqueConstraint("user_id", "code", name="uq_recovery_code"),)
|
||||
|
||||
user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), nullable=False)
|
||||
code = sa.Column(sa.String(16), nullable=False)
|
||||
code = sa.Column(sa.String(64), nullable=False)
|
||||
used = sa.Column(sa.Boolean, nullable=False, default=False)
|
||||
used_at = sa.Column(ArrowType, nullable=True, default=None)
|
||||
|
||||
user = orm.relationship(User)
|
||||
|
||||
@classmethod
|
||||
def _hash_code(cls, code: str) -> str:
|
||||
code_hmac = hmac.new(
|
||||
config.RECOVERY_CODE_HMAC_SECRET.encode("utf-8"),
|
||||
code.encode("utf-8"),
|
||||
"sha3_224",
|
||||
)
|
||||
return base64.urlsafe_b64encode(code_hmac.digest()).decode("utf-8").rstrip("=")
|
||||
|
||||
@classmethod
|
||||
def generate(cls, user):
|
||||
"""generate recovery codes for user"""
|
||||
|
@ -2697,14 +2706,27 @@ class RecoveryCode(Base, ModelMixin):
|
|||
Session.flush()
|
||||
|
||||
nb_code = 0
|
||||
raw_codes = []
|
||||
while nb_code < _NB_RECOVERY_CODE:
|
||||
code = random_string(_RECOVERY_CODE_LENGTH)
|
||||
if not cls.get_by(user_id=user.id, code=code):
|
||||
cls.create(user_id=user.id, code=code)
|
||||
raw_code = random_string(_RECOVERY_CODE_LENGTH)
|
||||
encoded_code = cls._hash_code(raw_code)
|
||||
if not cls.get_by(user_id=user.id, code=encoded_code):
|
||||
cls.create(user_id=user.id, code=encoded_code)
|
||||
raw_codes.append(raw_code)
|
||||
nb_code += 1
|
||||
|
||||
LOG.d("Create recovery codes for %s", user)
|
||||
Session.commit()
|
||||
return raw_codes
|
||||
|
||||
@classmethod
|
||||
def find_by_user_code(cls, user: User, code: str):
|
||||
hashed_code = cls._hash_code(code)
|
||||
# TODO: Only return hashed codes once there aren't unhashed codes in the db.
|
||||
found_code = cls.get_by(user_id=user.id, code=hashed_code)
|
||||
if found_code:
|
||||
return found_code
|
||||
return cls.get_by(user_id=user.id, code=code)
|
||||
|
||||
@classmethod
|
||||
def empty(cls, user):
|
||||
|
@ -3301,7 +3323,6 @@ class NewsletterUser(Base, ModelMixin):
|
|||
|
||||
|
||||
class ApiToCookieToken(Base, ModelMixin):
|
||||
|
||||
__tablename__ = "api_cookie_token"
|
||||
code = sa.Column(sa.String(128), unique=True, nullable=False)
|
||||
user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), nullable=False)
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
"""Updated recovery code string length
|
||||
|
||||
Revision ID: bd95b2b4217f
|
||||
Revises: 9cc0f0712b29
|
||||
Create Date: 2022-09-27 16:14:35.021846
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'bd95b2b4217f'
|
||||
down_revision = '9cc0f0712b29'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.execute('ALTER TABLE recovery_code ALTER COLUMN code TYPE VARCHAR(64)')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.execute('ALTER TABLE recovery_code ALTER COLUMN code TYPE VARCHAR(16)')
|
||||
# ### end Alembic commands ###
|
57
shell.py
57
shell.py
|
@ -4,19 +4,13 @@ import flask_migrate
|
|||
from IPython import embed
|
||||
from sqlalchemy_utils import create_database, database_exists, drop_database
|
||||
|
||||
from app import models
|
||||
from app.config import DB_URI
|
||||
from app.db import Session
|
||||
from app.email_utils import send_email, render
|
||||
from app.log import LOG
|
||||
from app.models import *
|
||||
from job_runner import (
|
||||
onboarding_pgp,
|
||||
onboarding_browser_extension,
|
||||
onboarding_mailbox,
|
||||
onboarding_send_from_alias,
|
||||
)
|
||||
|
||||
|
||||
if False:
|
||||
# noinspection PyUnreachableCode
|
||||
def create_db():
|
||||
if not database_exists(DB_URI):
|
||||
LOG.d("db not exist, create database")
|
||||
|
@ -26,6 +20,12 @@ def create_db():
|
|||
# Use flask-migrate instead of db.create_all()
|
||||
flask_migrate.upgrade()
|
||||
|
||||
# noinspection PyUnreachableCode
|
||||
def reset_db():
|
||||
if database_exists(DB_URI):
|
||||
drop_database(DB_URI)
|
||||
create_db()
|
||||
|
||||
|
||||
def change_password(user_id, new_password):
|
||||
user = User.get(user_id)
|
||||
|
@ -33,10 +33,41 @@ def change_password(user_id, new_password):
|
|||
Session.commit()
|
||||
|
||||
|
||||
def reset_db():
|
||||
if database_exists(DB_URI):
|
||||
drop_database(DB_URI)
|
||||
create_db()
|
||||
def migrate_recovery_codes():
|
||||
last_id = -1
|
||||
while True:
|
||||
recovery_codes = (
|
||||
RecoveryCode.filter(RecoveryCode.id > last_id)
|
||||
.order_by(RecoveryCode.id)
|
||||
.limit(100)
|
||||
.all()
|
||||
)
|
||||
batch_codes = len(recovery_codes)
|
||||
old_codes = 0
|
||||
new_codes = 0
|
||||
last_code = None
|
||||
last_code_id = None
|
||||
for recovery_code in recovery_codes:
|
||||
if len(recovery_code.code) == models._RECOVERY_CODE_LENGTH:
|
||||
last_code = recovery_code.code
|
||||
last_code_id = recovery_code.id
|
||||
recovery_code.code = RecoveryCode._hash_code(recovery_code.code)
|
||||
old_codes += 1
|
||||
Session.flush()
|
||||
else:
|
||||
new_codes += 1
|
||||
last_id = recovery_code.id
|
||||
Session.commit()
|
||||
LOG.i(
|
||||
f"Updated {old_codes}/{batch_codes} for this batch ({new_codes} already updated)"
|
||||
)
|
||||
if last_code is not None:
|
||||
recovery_code = RecoveryCode.get_by(id=last_code_id)
|
||||
assert RecoveryCode._hash_code(last_code) == recovery_code.code
|
||||
LOG.i("Check is Good")
|
||||
|
||||
if len(recovery_codes) == 0:
|
||||
break
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
@ -12,29 +12,15 @@
|
|||
your account again.
|
||||
Each code can only be used once, make sure to store them in a safe place.
|
||||
</p>
|
||||
<p class="alert alert-warning">
|
||||
<strong>
|
||||
If you had recovery codes before, they have been invalidated.
|
||||
Store these codes in a safe place. You won't be able to retrieve them again!
|
||||
</strong>
|
||||
</p>
|
||||
<ul>
|
||||
{% for recovery_code in recovery_codes %}
|
||||
|
||||
{% if recovery_code.used %}
|
||||
|
||||
<li>
|
||||
<span style="text-decoration: line-through">{{ recovery_code.code }}</span>.
|
||||
Used {{ recovery_code.used_at | dt }}.
|
||||
</li>
|
||||
{% else %}
|
||||
<li>{{ recovery_code.code }}</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for recovery_code in recovery_codes %}<li>{{ recovery_code }}</li>{% endfor %}
|
||||
</ul>
|
||||
<form method="post" class="mt-6">
|
||||
<input type="submit"
|
||||
class="btn btn-outline-primary"
|
||||
value="Generate New Codes">
|
||||
</form>
|
||||
<div class="small-text">Warning: Generating new codes will invalidate the older ones.</div>
|
||||
<hr />
|
||||
<a href="{{ url_for('dashboard.index') }}"
|
||||
class="btn btn-primary btn-lg">Back to the home page</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -102,8 +102,6 @@
|
|||
{% else %}
|
||||
<a href="{{ url_for('dashboard.mfa_cancel') }}"
|
||||
class="btn btn-outline-danger">Disable TOTP</a>
|
||||
<a href="{{ url_for('dashboard.recovery_code_route') }}"
|
||||
class="btn btn-outline-secondary">Recovery Codes</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -124,8 +122,6 @@
|
|||
{% else %}
|
||||
<a href="{{ url_for('dashboard.fido_manage') }}"
|
||||
class="btn btn-outline-info">Manage WebAuthn</a>
|
||||
<a href="{{ url_for('dashboard.recovery_code_route') }}"
|
||||
class="btn btn-outline-secondary">Recovery Codes</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -264,14 +260,10 @@
|
|||
<div class="card" id="change_password">
|
||||
<div class="card-body">
|
||||
<div class="card-title">Password</div>
|
||||
<div class="mb-3">
|
||||
You will receive an email containing instructions on how to change your password.
|
||||
</div>
|
||||
<div class="mb-3">You will receive an email containing instructions on how to change your password.</div>
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="change-password">
|
||||
<button class="btn btn-outline-primary">
|
||||
Change password
|
||||
</button>
|
||||
<button class="btn btn-outline-primary">Change password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -62,3 +62,5 @@ PROTON_CLIENT_SECRET=to_fill
|
|||
PROTON_BASE_URL=https://localhost/api
|
||||
|
||||
POSTMASTER=postmaster@test.domain
|
||||
|
||||
RECOVERY_CODE_HMAC_SECRET=1234567890123456789
|
Loading…
Reference in a new issue