mirror of
https://github.com/simple-login/app.git
synced 2024-09-30 05:31:30 +02:00
commit
fb17dca2cf
@ -13,4 +13,5 @@ from .views import (
|
|||||||
mfa,
|
mfa,
|
||||||
fido,
|
fido,
|
||||||
social,
|
social,
|
||||||
|
recovery,
|
||||||
)
|
)
|
||||||
|
@ -28,11 +28,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if enable_otp %}
|
{% if enable_otp %}
|
||||||
<div class="text-center text-muted mb-6" style="margin-top: 1em;">
|
<hr>
|
||||||
|
<div class="text-muted mt-5" style="margin-top: 1em;">
|
||||||
Don't have your key with you? <br> <a href="{{ url_for('auth.mfa') }}">Verify by One-Time Password</a>
|
Don't have your key with you? <br> <a href="{{ url_for('auth.mfa') }}">Verify by One-Time Password</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
If you have troubles with your authentication app, you can use the recovery code to login. <br>
|
||||||
|
<a href="{{ url_for('auth.recovery_route', next=next_url) }}">Use Recovery Codes</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
async function verifyKey() {
|
async function verifyKey() {
|
||||||
$("#btnVerifyKey").prop('disabled', true);
|
$("#btnVerifyKey").prop('disabled', true);
|
||||||
|
@ -20,7 +20,7 @@
|
|||||||
<input type="hidden" name="form-name" value="create">
|
<input type="hidden" name="form-name" value="create">
|
||||||
|
|
||||||
<div class="font-weight-bold mt-5">Token</div>
|
<div class="font-weight-bold mt-5">Token</div>
|
||||||
<div class="small-text">Please enter the 6-digit number displayed in your MFA application
|
<div class="small-text mb-3">Please enter the 6-digit number displayed in your MFA application
|
||||||
(Google Authenticator, Authy, MyDigiPassword, etc) here
|
(Google Authenticator, Authy, MyDigiPassword, etc) here
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -30,11 +30,20 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if enable_fido %}
|
{% if enable_fido %}
|
||||||
<div class="text-center text-muted mb-6" style="margin-top: 1em;">
|
<hr>
|
||||||
|
<div class="text-muted mt-5" style="margin-top: 1em;">
|
||||||
Having trouble with your authenticator? <br> <a href="{{ url_for('auth.fido') }}">Verify by your security
|
Having trouble with your authenticator? <br> <a href="{{ url_for('auth.fido') }}">Verify by your security
|
||||||
key</a>
|
key</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<div class="mt-5">
|
||||||
|
If you have troubles with your authentication app, you can use the recovery code to login. <br>
|
||||||
|
<a href="{{ url_for('auth.recovery_route', next=next_url) }}">Use Recovery Codes</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
27
app/auth/templates/auth/recovery.html
Normal file
27
app/auth/templates/auth/recovery.html
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{% extends "single.html" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
Recovery Code
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block single_content %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-6">
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{{ recovery_form.csrf_token }}
|
||||||
|
|
||||||
|
<div class="font-weight-bold mt-5">Code</div>
|
||||||
|
<div class="small-text">Please enter one of the recovery codes here
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ recovery_form.code(class="form-control", autofocus="true") }}
|
||||||
|
{{ render_field_errors(recovery_form.code) }}
|
||||||
|
<button class="btn btn-success mt-2">Submit</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -104,4 +104,5 @@ def fido():
|
|||||||
webauthn_assertion_options=webauthn_assertion_options,
|
webauthn_assertion_options=webauthn_assertion_options,
|
||||||
enable_otp=user.enable_otp,
|
enable_otp=user.enable_otp,
|
||||||
auto_activate=auto_activate,
|
auto_activate=auto_activate,
|
||||||
|
next_url=next_url,
|
||||||
)
|
)
|
||||||
|
@ -19,13 +19,13 @@ def after_login(user, next_url):
|
|||||||
# switch between these two 2FA option
|
# switch between these two 2FA option
|
||||||
session[MFA_USER_ID] = user.id
|
session[MFA_USER_ID] = user.id
|
||||||
if next_url:
|
if next_url:
|
||||||
return redirect(url_for("auth.fido", next_url=next_url))
|
return redirect(url_for("auth.fido", next=next_url))
|
||||||
else:
|
else:
|
||||||
return redirect(url_for("auth.fido"))
|
return redirect(url_for("auth.fido"))
|
||||||
elif user.enable_otp:
|
elif user.enable_otp:
|
||||||
session[MFA_USER_ID] = user.id
|
session[MFA_USER_ID] = user.id
|
||||||
if next_url:
|
if next_url:
|
||||||
return redirect(url_for("auth.mfa", next_url=next_url))
|
return redirect(url_for("auth.mfa", next=next_url))
|
||||||
else:
|
else:
|
||||||
return redirect(url_for("auth.mfa"))
|
return redirect(url_for("auth.mfa"))
|
||||||
else:
|
else:
|
||||||
|
@ -59,4 +59,5 @@ def mfa():
|
|||||||
"auth/mfa.html",
|
"auth/mfa.html",
|
||||||
otp_token_form=otp_token_form,
|
otp_token_form=otp_token_form,
|
||||||
enable_fido=(user.fido_enabled()),
|
enable_fido=(user.fido_enabled()),
|
||||||
|
next_url=next_url,
|
||||||
)
|
)
|
||||||
|
65
app/auth/views/recovery.py
Normal file
65
app/auth/views/recovery.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import arrow
|
||||||
|
import pyotp
|
||||||
|
from flask import request, render_template, redirect, url_for, flash, session
|
||||||
|
from flask_login import login_user
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import StringField, validators
|
||||||
|
|
||||||
|
from app.auth.base import auth_bp
|
||||||
|
from app.config import MFA_USER_ID
|
||||||
|
from app.extensions import db
|
||||||
|
from app.log import LOG
|
||||||
|
from app.models import User, RecoveryCode
|
||||||
|
|
||||||
|
|
||||||
|
class RecoveryForm(FlaskForm):
|
||||||
|
code = StringField("Code", validators=[validators.DataRequired()])
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/recovery", methods=["GET", "POST"])
|
||||||
|
def recovery_route():
|
||||||
|
# passed from login page
|
||||||
|
user_id = session.get(MFA_USER_ID)
|
||||||
|
|
||||||
|
# user access this page directly without passing by login page
|
||||||
|
if not user_id:
|
||||||
|
flash("Unknown error, redirect back to main page", "warning")
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
user = User.get(user_id)
|
||||||
|
|
||||||
|
if not user.two_factor_authentication_enabled():
|
||||||
|
flash("Only user with MFA enabled should go to this page", "warning")
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
recovery_form = RecoveryForm()
|
||||||
|
next_url = request.args.get("next")
|
||||||
|
|
||||||
|
if recovery_form.validate_on_submit():
|
||||||
|
code = recovery_form.code.data
|
||||||
|
recovery_code = RecoveryCode.get_by(user_id=user.id, code=code)
|
||||||
|
|
||||||
|
if recovery_code:
|
||||||
|
if recovery_code.used:
|
||||||
|
flash("Code already used", "error")
|
||||||
|
else:
|
||||||
|
del session[MFA_USER_ID]
|
||||||
|
|
||||||
|
login_user(user)
|
||||||
|
flash(f"Welcome back {user.name}!", "success")
|
||||||
|
|
||||||
|
recovery_code.used = True
|
||||||
|
recovery_code.used_at = arrow.now()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# 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"))
|
||||||
|
else:
|
||||||
|
flash("Incorrect code", "error")
|
||||||
|
|
||||||
|
return render_template("auth/recovery.html", recovery_form=recovery_form)
|
@ -20,4 +20,5 @@ from .views import (
|
|||||||
mailbox_detail,
|
mailbox_detail,
|
||||||
refused_email,
|
refused_email,
|
||||||
referral,
|
referral,
|
||||||
|
recovery_code,
|
||||||
)
|
)
|
||||||
|
40
app/dashboard/templates/dashboard/recovery_code.html
Normal file
40
app/dashboard/templates/dashboard/recovery_code.html
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{% extends 'default.html' %}
|
||||||
|
{% set active_page = "setting" %}
|
||||||
|
{% block title %}
|
||||||
|
Recovery Codes
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block default_content %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h1 class="h3">Recovery codes</h1>
|
||||||
|
<p>
|
||||||
|
If you lose access to your authentication device, you can use one of these backup codes to login to your
|
||||||
|
account. Each code may be used only once. Make a copy of these codes, and store it somewhere safe.
|
||||||
|
</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 %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form method="post" class="mt-6">
|
||||||
|
<input type="submit" class="btn btn-outline-primary" value="Generate New Codes">
|
||||||
|
</form>
|
||||||
|
<div class="small-text">
|
||||||
|
Beware: Generating new codes invalidates all the previous ones, make sure to write down the new ones!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@ -98,6 +98,7 @@
|
|||||||
<a href="{{ url_for('dashboard.fido_setup') }}" class="btn btn-outline-primary">Setup WebAuthn</a>
|
<a href="{{ url_for('dashboard.fido_setup') }}" class="btn btn-outline-primary">Setup WebAuthn</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for('dashboard.fido_cancel') }}" class="btn btn-outline-danger">Disable WebAuthn</a>
|
<a href="{{ url_for('dashboard.fido_cancel') }}" class="btn btn-outline-danger">Disable WebAuthn</a>
|
||||||
|
<a href="{{ url_for('dashboard.recovery_code_route') }}" class="btn btn-outline-secondary">Recovery Codes</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -114,6 +115,7 @@
|
|||||||
<a href="{{ url_for('dashboard.mfa_setup') }}" class="btn btn-outline-primary">Setup TOTP</a>
|
<a href="{{ url_for('dashboard.mfa_setup') }}" class="btn btn-outline-primary">Setup TOTP</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for('dashboard.mfa_cancel') }}" class="btn btn-outline-danger">Disable TOTP</a>
|
<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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,6 +5,7 @@ from wtforms import PasswordField, validators
|
|||||||
|
|
||||||
from app.dashboard.base import dashboard_bp
|
from app.dashboard.base import dashboard_bp
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.models import RecoveryCode
|
||||||
|
|
||||||
|
|
||||||
class LoginForm(FlaskForm):
|
class LoginForm(FlaskForm):
|
||||||
@ -29,6 +30,11 @@ def fido_cancel():
|
|||||||
current_user.fido_sign_count = None
|
current_user.fido_sign_count = None
|
||||||
current_user.fido_credential_id = None
|
current_user.fido_credential_id = None
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
# user does not have any 2FA enabled left, delete all recovery codes
|
||||||
|
if not current_user.two_factor_authentication_enabled():
|
||||||
|
RecoveryCode.empty(current_user)
|
||||||
|
|
||||||
flash("We've unlinked your security key.", "success")
|
flash("We've unlinked your security key.", "success")
|
||||||
return redirect(url_for("dashboard.index"))
|
return redirect(url_for("dashboard.index"))
|
||||||
else:
|
else:
|
||||||
|
@ -68,8 +68,7 @@ def fido_setup():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
flash("Security key has been activated", "success")
|
flash("Security key has been activated", "success")
|
||||||
|
return redirect(url_for("dashboard.recovery_code_route"))
|
||||||
return redirect(url_for("dashboard.index"))
|
|
||||||
|
|
||||||
# Prepare information for key registration process
|
# Prepare information for key registration process
|
||||||
fido_uuid = str(uuid.uuid4())
|
fido_uuid = str(uuid.uuid4())
|
||||||
|
@ -6,6 +6,7 @@ from wtforms import StringField, validators
|
|||||||
|
|
||||||
from app.dashboard.base import dashboard_bp
|
from app.dashboard.base import dashboard_bp
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.models import RecoveryCode
|
||||||
|
|
||||||
|
|
||||||
class OtpTokenForm(FlaskForm):
|
class OtpTokenForm(FlaskForm):
|
||||||
@ -29,6 +30,11 @@ def mfa_cancel():
|
|||||||
current_user.enable_otp = False
|
current_user.enable_otp = False
|
||||||
current_user.otp_secret = None
|
current_user.otp_secret = None
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
# user does not have any 2FA enabled left, delete all recovery codes
|
||||||
|
if not current_user.two_factor_authentication_enabled():
|
||||||
|
RecoveryCode.empty(current_user)
|
||||||
|
|
||||||
flash("MFA is now disabled", "warning")
|
flash("MFA is now disabled", "warning")
|
||||||
return redirect(url_for("dashboard.index"))
|
return redirect(url_for("dashboard.index"))
|
||||||
else:
|
else:
|
||||||
|
@ -36,7 +36,8 @@ def mfa_setup():
|
|||||||
current_user.enable_otp = True
|
current_user.enable_otp = True
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("MFA has been activated", "success")
|
flash("MFA has been activated", "success")
|
||||||
return redirect(url_for("dashboard.index"))
|
|
||||||
|
return redirect(url_for("dashboard.recovery_code_route"))
|
||||||
else:
|
else:
|
||||||
flash("Incorrect token", "warning")
|
flash("Incorrect token", "warning")
|
||||||
|
|
||||||
|
30
app/dashboard/views/recovery_code.py
Normal file
30
app/dashboard/views/recovery_code.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
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.query.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.query.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
|
||||||
|
)
|
@ -164,6 +164,9 @@ class User(db.Model, ModelMixin, UserMixin):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def two_factor_authentication_enabled(self) -> bool:
|
||||||
|
return self.enable_otp or self.fido_enabled()
|
||||||
|
|
||||||
# some users could have lifetime premium
|
# some users could have lifetime premium
|
||||||
lifetime = db.Column(db.Boolean, default=False, nullable=False, server_default="0")
|
lifetime = db.Column(db.Boolean, default=False, nullable=False, server_default="0")
|
||||||
|
|
||||||
@ -1344,3 +1347,43 @@ class AliasMailbox(db.Model, ModelMixin):
|
|||||||
mailbox_id = db.Column(
|
mailbox_id = db.Column(
|
||||||
db.ForeignKey(Mailbox.id, ondelete="cascade"), nullable=False
|
db.ForeignKey(Mailbox.id, ondelete="cascade"), nullable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_NB_RECOVERY_CODE = 8
|
||||||
|
_RECOVERY_CODE_LENGTH = 8
|
||||||
|
|
||||||
|
|
||||||
|
class RecoveryCode(db.Model, ModelMixin):
|
||||||
|
"""allow user to login in case you lose any of your authenticators"""
|
||||||
|
|
||||||
|
__table_args__ = (db.UniqueConstraint("user_id", "code", name="uq_recovery_code"),)
|
||||||
|
|
||||||
|
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
|
||||||
|
code = db.Column(db.String(16), nullable=False)
|
||||||
|
used = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
used_at = db.Column(ArrowType, nullable=True, default=None)
|
||||||
|
|
||||||
|
user = db.relationship(User)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate(cls, user):
|
||||||
|
"""generate recovery codes for user"""
|
||||||
|
# delete all existing codes
|
||||||
|
cls.query.filter_by(user_id=user.id).delete()
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
nb_code = 0
|
||||||
|
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)
|
||||||
|
nb_code += 1
|
||||||
|
|
||||||
|
LOG.d("Create recovery codes for %s", user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def empty(cls, user):
|
||||||
|
"""Delete all recovery codes for user"""
|
||||||
|
cls.query.filter_by(user_id=user.id).delete()
|
||||||
|
db.session.commit()
|
||||||
|
40
migrations/versions/2020_051710_c31cdf879ee3_.py
Normal file
40
migrations/versions/2020_051710_c31cdf879ee3_.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: c31cdf879ee3
|
||||||
|
Revises: 5cad8fa84386
|
||||||
|
Create Date: 2020-05-17 10:34:23.492008
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'c31cdf879ee3'
|
||||||
|
down_revision = '5cad8fa84386'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('recovery_code',
|
||||||
|
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('code', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('used', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('used_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('user_id', 'code', name='uq_recovery_code')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('recovery_code')
|
||||||
|
# ### end Alembic commands ###
|
Loading…
Reference in New Issue
Block a user