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():
|
if recovery_form.validate_on_submit():
|
||||||
code = recovery_form.code.data
|
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:
|
||||||
if recovery_code.used:
|
if recovery_code.used:
|
||||||
|
|
|
@ -494,3 +494,12 @@ JOB_TAKEN_RETRY_WAIT_MINS = 30
|
||||||
|
|
||||||
# MEM_STORE
|
# MEM_STORE
|
||||||
MEM_STORE_URI = os.environ.get("MEM_STORE_URI", None)
|
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,
|
mailbox_detail,
|
||||||
refused_email,
|
refused_email,
|
||||||
referral,
|
referral,
|
||||||
recovery_code,
|
|
||||||
contact_detail,
|
contact_detail,
|
||||||
setup_done,
|
setup_done,
|
||||||
batch_import,
|
batch_import,
|
||||||
|
|
|
@ -78,10 +78,10 @@ def fido_setup():
|
||||||
)
|
)
|
||||||
|
|
||||||
flash("Security key has been activated", "success")
|
flash("Security key has been activated", "success")
|
||||||
if not RecoveryCode.filter_by(user_id=current_user.id).all():
|
recovery_codes = RecoveryCode.generate(current_user)
|
||||||
return redirect(url_for("dashboard.recovery_code_route"))
|
return render_template(
|
||||||
else:
|
"dashboard/recovery_code.html", recovery_codes=recovery_codes
|
||||||
return redirect(url_for("dashboard.fido_manage"))
|
)
|
||||||
|
|
||||||
# Prepare information for key registration process
|
# Prepare information for key registration process
|
||||||
fido_uuid = (
|
fido_uuid = (
|
||||||
|
|
|
@ -8,6 +8,7 @@ from app.dashboard.base import dashboard_bp
|
||||||
from app.dashboard.views.enter_sudo import sudo_required
|
from app.dashboard.views.enter_sudo import sudo_required
|
||||||
from app.db import Session
|
from app.db import Session
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
|
from app.models import RecoveryCode
|
||||||
|
|
||||||
|
|
||||||
class OtpTokenForm(FlaskForm):
|
class OtpTokenForm(FlaskForm):
|
||||||
|
@ -39,8 +40,10 @@ def mfa_setup():
|
||||||
current_user.last_otp = token
|
current_user.last_otp = token
|
||||||
Session.commit()
|
Session.commit()
|
||||||
flash("MFA has been activated", "success")
|
flash("MFA has been activated", "success")
|
||||||
|
recovery_codes = RecoveryCode.generate(current_user)
|
||||||
return redirect(url_for("dashboard.recovery_code_route"))
|
return render_template(
|
||||||
|
"dashboard/recovery_code.html", recovery_codes=recovery_codes
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
flash("Incorrect token", "warning")
|
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"),)
|
__table_args__ = (sa.UniqueConstraint("user_id", "code", name="uq_recovery_code"),)
|
||||||
|
|
||||||
user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), nullable=False)
|
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 = sa.Column(sa.Boolean, nullable=False, default=False)
|
||||||
used_at = sa.Column(ArrowType, nullable=True, default=None)
|
used_at = sa.Column(ArrowType, nullable=True, default=None)
|
||||||
|
|
||||||
user = orm.relationship(User)
|
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
|
@classmethod
|
||||||
def generate(cls, user):
|
def generate(cls, user):
|
||||||
"""generate recovery codes for user"""
|
"""generate recovery codes for user"""
|
||||||
|
@ -2697,14 +2706,27 @@ class RecoveryCode(Base, ModelMixin):
|
||||||
Session.flush()
|
Session.flush()
|
||||||
|
|
||||||
nb_code = 0
|
nb_code = 0
|
||||||
|
raw_codes = []
|
||||||
while nb_code < _NB_RECOVERY_CODE:
|
while nb_code < _NB_RECOVERY_CODE:
|
||||||
code = random_string(_RECOVERY_CODE_LENGTH)
|
raw_code = random_string(_RECOVERY_CODE_LENGTH)
|
||||||
if not cls.get_by(user_id=user.id, code=code):
|
encoded_code = cls._hash_code(raw_code)
|
||||||
cls.create(user_id=user.id, code=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
|
nb_code += 1
|
||||||
|
|
||||||
LOG.d("Create recovery codes for %s", user)
|
LOG.d("Create recovery codes for %s", user)
|
||||||
Session.commit()
|
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
|
@classmethod
|
||||||
def empty(cls, user):
|
def empty(cls, user):
|
||||||
|
@ -3301,7 +3323,6 @@ class NewsletterUser(Base, ModelMixin):
|
||||||
|
|
||||||
|
|
||||||
class ApiToCookieToken(Base, ModelMixin):
|
class ApiToCookieToken(Base, ModelMixin):
|
||||||
|
|
||||||
__tablename__ = "api_cookie_token"
|
__tablename__ = "api_cookie_token"
|
||||||
code = sa.Column(sa.String(128), unique=True, nullable=False)
|
code = sa.Column(sa.String(128), unique=True, nullable=False)
|
||||||
user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), 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 ###
|
71
shell.py
71
shell.py
|
@ -4,27 +4,27 @@ import flask_migrate
|
||||||
from IPython import embed
|
from IPython import embed
|
||||||
from sqlalchemy_utils import create_database, database_exists, drop_database
|
from sqlalchemy_utils import create_database, database_exists, drop_database
|
||||||
|
|
||||||
|
from app import models
|
||||||
from app.config import DB_URI
|
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 app.models import *
|
||||||
from job_runner import (
|
|
||||||
onboarding_pgp,
|
|
||||||
onboarding_browser_extension,
|
|
||||||
onboarding_mailbox,
|
|
||||||
onboarding_send_from_alias,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_db():
|
if False:
|
||||||
if not database_exists(DB_URI):
|
# noinspection PyUnreachableCode
|
||||||
LOG.d("db not exist, create database")
|
def create_db():
|
||||||
create_database(DB_URI)
|
if not database_exists(DB_URI):
|
||||||
|
LOG.d("db not exist, create database")
|
||||||
|
create_database(DB_URI)
|
||||||
|
|
||||||
# Create all tables
|
# Create all tables
|
||||||
# Use flask-migrate instead of db.create_all()
|
# Use flask-migrate instead of db.create_all()
|
||||||
flask_migrate.upgrade()
|
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):
|
def change_password(user_id, new_password):
|
||||||
|
@ -33,10 +33,41 @@ def change_password(user_id, new_password):
|
||||||
Session.commit()
|
Session.commit()
|
||||||
|
|
||||||
|
|
||||||
def reset_db():
|
def migrate_recovery_codes():
|
||||||
if database_exists(DB_URI):
|
last_id = -1
|
||||||
drop_database(DB_URI)
|
while True:
|
||||||
create_db()
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|
|
@ -12,29 +12,15 @@
|
||||||
your account again.
|
your account again.
|
||||||
Each code can only be used once, make sure to store them in a safe place.
|
Each code can only be used once, make sure to store them in a safe place.
|
||||||
</p>
|
</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>
|
<ul>
|
||||||
{% for recovery_code in recovery_codes %}
|
{% for recovery_code in recovery_codes %}<li>{{ recovery_code }}</li>{% endfor %}
|
||||||
|
|
||||||
{% 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 %}
|
|
||||||
</ul>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -102,8 +102,6 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for('dashboard.mfa_cancel') }}"
|
<a href="{{ url_for('dashboard.mfa_cancel') }}"
|
||||||
class="btn btn-outline-danger">Disable TOTP</a>
|
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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -124,8 +122,6 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for('dashboard.fido_manage') }}"
|
<a href="{{ url_for('dashboard.fido_manage') }}"
|
||||||
class="btn btn-outline-info">Manage WebAuthn</a>
|
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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -264,14 +260,10 @@
|
||||||
<div class="card" id="change_password">
|
<div class="card" id="change_password">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-title">Password</div>
|
<div class="card-title">Password</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">You will receive an email containing instructions on how to change your password.</div>
|
||||||
You will receive an email containing instructions on how to change your password.
|
|
||||||
</div>
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="form-name" value="change-password">
|
<input type="hidden" name="form-name" value="change-password">
|
||||||
<button class="btn btn-outline-primary">
|
<button class="btn btn-outline-primary">Change password</button>
|
||||||
Change password
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -62,3 +62,5 @@ PROTON_CLIENT_SECRET=to_fill
|
||||||
PROTON_BASE_URL=https://localhost/api
|
PROTON_BASE_URL=https://localhost/api
|
||||||
|
|
||||||
POSTMASTER=postmaster@test.domain
|
POSTMASTER=postmaster@test.domain
|
||||||
|
|
||||||
|
RECOVERY_CODE_HMAC_SECRET=1234567890123456789
|
Loading…
Reference in a new issue