Improved magic link login flow

This commit is contained in:
Ozzie Isaacs
2026-08-01 18:47:25 +02:00
parent a07affbd12
commit afc1e85803
4 changed files with 74 additions and 20 deletions
+38 -19
View File
@@ -38,6 +38,27 @@ remotelogin = Blueprint('remotelogin', __name__)
log = logger.create()
def _load_remote_auth_token(token):
auth_token = ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.auth_token == token).first()
# Token not found
if auth_token is None:
flash(_("Token not found"), category="error")
log.error("Remote Login token not found")
return None
# Token expired
if datetime.now() > auth_token.expiration:
ub.session.delete(auth_token)
ub.session_commit()
flash(_("Token has expired"), category="error")
log.error("Remote Login token expired")
return None
return auth_token
def remote_login_required(f):
@wraps(f)
def inner(*args, **kwargs):
@@ -56,6 +77,8 @@ def remote_login_required(f):
@remote_login_required
def remote_login():
auth_token = ub.RemoteAuthToken()
auth_token.request_ip = request.remote_addr
auth_token.request_user_agent = request.headers.get('User-Agent', "")
ub.session.add(auth_token)
ub.session_commit()
verify_url = url_for('remotelogin.verify_token', token=auth_token.auth_token, _external=true)
@@ -64,35 +87,31 @@ def remote_login():
verify_url=verify_url, page="remotelogin")
@remotelogin.route('/verify/<token>')
@remotelogin.route('/verify/<token>', methods=['GET', 'POST'])
@remote_login_required
@user_login_required
def verify_token(token):
auth_token = ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.auth_token == token).first()
# Token not found
auth_token = _load_remote_auth_token(token)
if auth_token is None:
flash(_("Token not found"), category="error")
log.error("Remote Login token not found")
return redirect(url_for('web.index'))
# Token expired
elif datetime.now() > auth_token.expiration:
ub.session.delete(auth_token)
if request.method == 'POST':
if auth_token.verified:
flash(_("Token already approved"), category="error")
log.error("Remote Login token already approved")
return redirect(url_for('web.index'))
auth_token.user_id = current_user.id
auth_token.verified = True
ub.session_commit()
flash(_("Token has expired"), category="error")
log.error("Remote Login token expired")
flash(_("Success! Please return to your device"), category="success")
log.debug("Remote Login token for userid %s verified", auth_token.user_id)
return redirect(url_for('web.index'))
# Update token with user information
auth_token.user_id = current_user.id
auth_token.verified = True
ub.session_commit()
flash(_("Success! Please return to your device"), category="success")
log.debug("Remote Login token for userid %s verified", auth_token.user_id)
return redirect(url_for('web.index'))
return render_title_template('remote_login_verify.html', title=_("Login"), token=auth_token.auth_token,
request_ip=auth_token.request_ip,
request_user_agent=auth_token.request_user_agent, page="remotelogin")
@remotelogin.route('/ajax/verify_token', methods=['POST'])
+2 -1
View File
@@ -17,8 +17,9 @@
(function () {
// Poll the server to check if the user has authenticated
var csrf_token = $("input[name='csrf_token']").val();
var t = setInterval(function () {
$.post(getPath() + "/ajax/verify_token", { token: $("#verify_url").data("token") })
$.post(getPath() + "/ajax/verify_token", { token: $("#verify_url").data("token"), csrf_token: csrf_token })
.done(function(response) {
if (response.status === 'success') {
// Wait a tick so cookies are updated
+19
View File
@@ -0,0 +1,19 @@
{% extends "layout.html" %}
{% block body %}
<div class="well col-sm-6 col-sm-offset-2">
<h2 style="margin-top: 0">{{ _('Approve Magic Link Login') }}</h2>
<p>{{ _('Review the device that requested access before approving this login.') }}</p>
<dl class="dl-horizontal">
<dt>{{ _('IP address') }}</dt>
<dd>{{ request_ip or _('Unknown') }}</dd>
<dt>{{ _('Browser') }}</dt>
<dd>{{ request_user_agent or _('Unknown') }}</dd>
</dl>
<p>{{ _('If you approve this request, the device that started it will be logged in as %(username)s.', username=current_user.name) }}</p>
<form method="POST" role="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-primary">{{ _('Approve Login') }}</button>
<a class="btn btn-default" href="{{ url_for('web.index') }}">{{ _('Cancel') }}</a>
</form>
</div>
{% endblock %}
+15
View File
@@ -534,6 +534,8 @@ class RemoteAuthToken(Base):
verified = Column(Boolean, default=False)
expiration = Column(DateTime)
token_type = Column(Integer, default=0)
request_ip = Column(String)
request_user_agent = Column(String)
def __init__(self):
super().__init__()
@@ -601,6 +603,18 @@ def migrate_user_session_table(engine, _session):
trans.commit()
def migrate_remote_auth_token_table(engine, _session):
try:
_session.query(exists().where(RemoteAuthToken.request_ip)).scalar()
_session.commit()
except exc.OperationalError: # Database is not compatible, some columns are missing
with engine.connect() as conn:
trans = conn.begin()
conn.execute(text("ALTER TABLE remote_auth_token ADD column 'request_ip' String"))
conn.execute(text("ALTER TABLE remote_auth_token ADD column 'request_user_agent' String"))
trans.commit()
# Migrate database to current version, has to be updated after every database change. Currently, migration from
# maybe 4/5 versions back to current should work.
# Migration is done by checking if relevant columns are existing, and then adding rows with SQL commands
@@ -609,6 +623,7 @@ def migrate_Database(_session):
add_missing_tables(engine, _session)
migrate_registration_table(engine, _session)
migrate_user_session_table(engine, _session)
migrate_remote_auth_token_table(engine, _session)
def clean_database(_session):