feat(2fa): refactor logic arround validation/recoverycodes and workflows of configuration of 2fa

- recovery codes are shown in 1 place only
- save status about view of recovery codes
- made the logic of saving states into user_data more explicit and no longer relly on hacky DB transaction logic
- turn JS forms into a regular forms
This commit is contained in:
RhodeCode Admin 2024-04-24 09:45:36 +02:00
parent 2fcf5e8c05
commit 337031c7a4
11 changed files with 353 additions and 270 deletions

View file

@ -4,6 +4,7 @@
<div class="panel-heading">
<h3 class="panel-title">${_('Enable/Disable 2FA for your account')}</h3>
</div>
${h.secure_form(h.route_path('my_account_enable_2fa_save'), request=request)}
<div class="panel-body">
<div class="form">
<div class="fields">
@ -12,112 +13,122 @@
<label>${_('2FA status')}:</label>
</div>
<div class="checkboxes">
<div class="form-check">
<label class="form-check-label">
<input type="radio" id="2faEnabled" value="1" ${'checked' if c.state_of_2fa else ''}>
${_('Enabled')}
</label>
<label class="form-check-label">
<input type="radio" id="2faDisabled" value="0" ${'checked' if not c.state_of_2fa else ''}>
${_('Disabled')}
</label>
</div>
% if c.locked_2fa:
<span class="help-block">${_('2FA settings cannot be changed here, because 2FA was forced enabled by RhodeCode Administrator.')}</span>
% else:
<div class="form-check">
<input type="radio" id="2faEnabled" name="2fa_status" value="1" ${'checked=1' if c.state_of_2fa else ''}/>
<label for="2faEnabled">${_('Enable 2FA')}</label>
<input type="radio" id="2faDisabled" name="2fa_status" value="0" ${'checked=1' if not c.state_of_2fa else ''} />
<label for="2faDisabled">${_('Disable 2FA')}</label>
</div>
% endif
</div>
</div>
</div>
<button id="saveBtn" class="btn btn-primary" ${'disabled' if c.locked_2fa else ''}>${_('Save')}</button>
</div>
</div>
${h.end_form()}
</div>
% if c.state_of_2fa:
% if not c.user_seen_2fa_recovery_codes:
<div class="panel panel-warning">
<div class="panel-heading" id="advanced-archive">
<h3 class="panel-title">${_('2FA Recovery codes')} <a class="permalink" href="#advanced-archive"> ¶</a></h3>
</div>
<div class="panel-body">
<p>
${_('You have not seen your 2FA recovery codes yet.')}
${_('Please save them in a safe place, or you will lose access to your account in case of lost access to authenticator app.')}
</p>
<br/>
<a href="${request.route_path('my_account_enable_2fa', _query={'show-recovery-codes': 1})}" class="btn btn-primary">${_('Show recovery codes')}</a>
</div>
</div>
% endif
${h.secure_form(h.route_path('my_account_regenerate_2fa_recovery_codes'), request=request)}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">${_('Regenerate 2FA recovery codes for your account')}</h3>
</div>
<div class="panel-body">
<form id="2faForm">
<input type="text" name="totp" placeholder="${_('Verify the code from the app')}" pattern="\d{6}"
style="width: 20%">
<button type="button" class="btn btn-primary" onclick="submitForm()">Verify</button>
<input type="text" name="totp" placeholder="${_('Verify the code from the app')}" pattern="\d{6}" style="width: 20%">
<button type="submit" class="btn btn-primary">${_('Verify and generate new codes')}</button>
</form>
<div id="result"></div>
</div>
</div>
${h.end_form()}
% endif
<script>
function submitForm() {
let formData = new FormData(document.getElementById("2faForm"));
let xhr = new XMLHttpRequest();
let success = function (response) {
let recovery_codes = response.recovery_codes;
showRecoveryCodesPopup(recovery_codes);
}
xhr.onreadystatechange = function () {
if (xhr.readyState == 4 && xhr.status == 200) {
let responseDoc = new DOMParser().parseFromString(xhr.responseText, "text/html");
let contentToDisplay = responseDoc.querySelector('#formErrors');
if (contentToDisplay) {
document.getElementById("result").innerHTML = contentToDisplay.innerHTML;
} else {
let regenerate_url = pyroutes.url('my_account_regenerate_2fa_recovery_codes');
ajaxPOST(regenerate_url, {'csrf_token': CSRF_TOKEN}, success);
}
}
};
let url = pyroutes.url('check_2fa');
xhr.open("POST", url, true);
xhr.send(formData);
}
document.getElementById('2faEnabled').addEventListener('click', function () {
document.getElementById('2faDisabled').checked = false;
});
document.getElementById('2faDisabled').addEventListener('click', function () {
document.getElementById('2faEnabled').checked = false;
});
function getStateValue() {
if (document.getElementById('2faEnabled').checked) {
return '1';
} else {
return '0';
}
};
function saveChanges(state) {
let post_data = {'state': state, 'csrf_token': CSRF_TOKEN};
let url = pyroutes.url('my_account_configure_2fa');
ajaxPOST(url, post_data, function(){}, function(){})
}
document.getElementById('saveBtn').addEventListener('click', function () {
var state = getStateValue();
saveChanges(state);
});
function showRecoveryCodesPopup(recoveryCodes) {
let funcData = {'recoveryCodes': recoveryCodes}
let recoveryCodesHtml = renderTemplate('recoveryCodes', funcData)
function showRecoveryCodesPopup() {
SwalNoAnimation.fire({
allowOutsideClick: false,
confirmButtonText: _gettext('I Copied the codes'),
title: _gettext('2FA Recovery Codes'),
html: recoveryCodesHtml
title: _gettext('2FA recovery codes'),
html: '<span>Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one time each to regain access to your account. Please save them in a safe place, or you will lose access to your account.</span>',
showCancelButton: false,
showConfirmButton: true,
showLoaderOnConfirm: true,
confirmButtonText: _gettext('Show now'),
allowOutsideClick: function () {
!Swal.isLoading()
},
preConfirm: function () {
var postData = {
'csrf_token': CSRF_TOKEN
};
return new Promise(function (resolve, reject) {
$.ajax({
type: 'POST',
data: postData,
url: pyroutes.url('my_account_show_2fa_recovery_codes'),
headers: {'X-PARTIAL-XHR': true}
})
.done(function (data) {
resolve(data);
})
.fail(function (jqXHR, textStatus, errorThrown) {
var message = formatErrorMessage(jqXHR, textStatus, errorThrown);
ajaxErrorSwal(message);
});
})
}
})
.then(function (result) {
if (result.value) {
let funcData = {'recoveryCodes': result.value.recovery_codes}
let recoveryCodesHtml = renderTemplate('recoveryCodes', funcData);
SwalNoAnimation.fire({
allowOutsideClick: false,
confirmButtonText: _gettext('I Copied the codes'),
title: _gettext('2FA Recovery Codes'),
html: recoveryCodesHtml
}).then(function (result) {
if (result.isConfirmed) {
window.location.reload()
}
})
}
})
}
% if request.GET.get('show-recovery-codes') == '1' and not c.user_seen_2fa_recovery_codes:
showRecoveryCodesPopup();
% endif
</script>

View file

@ -1,7 +1,7 @@
<%inherit file="base/root.mako"/>
<%def name="title()">
${_('Setup authenticator app')}
${_('Setup 2FA')}
%if c.rhodecode_name:
&middot; ${h.branding(c.rhodecode_name)}
%endif
@ -22,31 +22,28 @@
</div>
<div class="loginwrapper">
<h1>Setup the authenticator app</h1>
<h1>${_('Setup the authenticator app')}</h1>
<p>Authenticator apps like <a href='https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2' target="_blank" rel="noopener noreferrer">Google Authenticator</a>, etc. generate one-time passwords that are used as a second factor to verify you identity.</p>
<rhodecode-toast id="notifications"></rhodecode-toast>
<div id="setup_2fa">
${h.secure_form(h.route_path('setup_2fa'), request=request, id='totp_form')}
<div class="sign-in-title">
<h1>${_('Scan the QR code')}</h1>
<h1>${_('Scan the QR code')}: "${totp_name}"</h1>
</div>
<p>Use an authenticator app to scan.</p>
<img src="data:image/png;base64, ${qr}"/>
<p>${_('Use an authenticator app to scan.')}</p>
<img alt="qr-code" src="data:image/png;base64, ${qr}"/>
<p>${_('Unable to scan?')} <a id="toggleLink">${_('Click here')}</a></p>
<div id="secretDiv" class="hidden">
<p>${_('Copy and use this code to manually setup an authenticator app')}</p>
<input type="text" id="secretField" value=${key}>
<i class="tooltip icon-clipboard clipboard-action" data-clipboard-text="" title="${_('Copy the secret key')}"></i>
<p>${_('Copy and use this code to manually set up an authenticator app')}</p>
<input type="text" class="input-monospace" value="${key}" id="secret_totp" name="secret_totp" style="width: 400px"/>
<i class="tooltip icon-clipboard clipboard-action" data-clipboard-text="${key}" title="${_('Copy the secret key')}"></i>
</div>
<div id="codesPopup" class="modal">
<div class="modal-content">
<ul id="recoveryCodesList"></ul>
<button id="copyAllBtn" class="btn btn-primary">Copy All</button>
</div>
</div>
<br><br>
<div id="verify_2fa">
${h.secure_form(h.route_path('setup_2fa'), request=request, id='totp_form')}
<div class="form mt-4">
<div class="field">
<p>
@ -59,13 +56,13 @@
<div class="input-group">
${h.text('totp', class_='form-control', style='width: 40%;')}
<div id="formErrors">
%if 'totp' in errors:
% if 'totp' in errors:
<span class="error-message">${errors.get('totp')}</span>
<br />
%endif
% endif
</div>
<div class="input-group-append">
${h.submit('save',_('Verify'),class_="btn btn-primary", style='width: 40%;', disabled=not codes_viewed)}
${h.submit('verify_2fa',_('Verify'),class_="btn btn-primary", style='width: 40%;')}
</div>
</div>
</div>
@ -73,81 +70,18 @@
</div>
</div>
</div>
${h.end_form()}
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
let clipboardIcons = document.querySelectorAll('.clipboard-action');
clipboardIcons.forEach(function(icon) {
icon.addEventListener('click', function() {
var inputField = document.getElementById('secretField');
inputField.select();
document.execCommand('copy');
});
});
});
</script>
<script>
document.getElementById('toggleLink').addEventListener('click', function() {
let hiddenField = document.getElementById('secretDiv');
if (hiddenField.classList.contains('hidden')) {
hiddenField.classList.remove('hidden');
}
});
</script>
<script>
const recovery_codes_string = '${recovery_codes}';
const cleaned_recovery_codes_string = recovery_codes_string
.replace(/&#34;/g, '"')
.replace(/&#39;/g, "'");
const recovery_codes = JSON.parse(cleaned_recovery_codes_string);
const cleaned_recovery_codes = recovery_codes.map(code => code.replace(/['"]/g, ''));
function showRecoveryCodesPopup() {
const popup = document.getElementById("codesPopup");
const codesList = document.getElementById("recoveryCodesList");
const verify_btn = document.getElementById('save')
if (verify_btn.disabled) {
codesList.innerHTML = "";
cleaned_recovery_codes.forEach(code => {
const listItem = document.createElement("li");
listItem.textContent = code;
codesList.appendChild(listItem);
});
popup.style.display = "block";
verify_btn.disabled = false;
}
}
document.getElementById("save").addEventListener("mouseover", showRecoveryCodesPopup);
const popup = document.getElementById("codesPopup");
const closeButton = document.querySelector(".close");
window.onclick = function(event) {
if (event.target === popup || event.target === closeButton) {
popup.style.display = "none";
}
document.getElementById('toggleLink').addEventListener('click', function() {
let hiddenField = document.getElementById('secretDiv');
if (hiddenField.classList.contains('hidden')) {
hiddenField.classList.remove('hidden');
}
document.getElementById("copyAllBtn").addEventListener("click", function() {
const codesListItems = document.querySelectorAll("#recoveryCodesList li");
const allCodes = Array.from(codesListItems).map(item => item.textContent).join(", ");
const textarea = document.createElement('textarea');
textarea.value = allCodes;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
});
</script>

View file

@ -1,37 +1,54 @@
<%inherit file="/base/root.mako"/>
<%inherit file="base/root.mako"/>
<%def name="title()">
${_('Check 2FA')}
${_('Verify 2FA')}
%if c.rhodecode_name:
&middot; ${h.branding(c.rhodecode_name)}
%endif
</%def>
<style>body{background-color:#eeeeee;}</style>
<div class="box">
<div class="verify2FA">
${h.secure_form(h.route_path('check_2fa'), request=request, id='totp_form')}
<div class="form mt-4" style="position: relative; margin-left: 35%; margin-top: 20%;">
<div class="field">
<p>
<div class="label">
<label for="totp" class="form-label text-dark font-weight-bold" style="text-align: left;">${_('Verify the code from the app')}:</label>
<div class="loginbox">
<div class="header-account">
<div id="header-inner" class="title">
<div id="logo">
% if c.rhodecode_name:
<div class="branding">
<a href="${h.route_path('home')}">${h.branding(c.rhodecode_name)}</a>
</div>
</p>
<p>
<div>
<div class="input-group">
${h.text('totp', class_="form-control", style='width: 38%;')}
<div id="formErrors">
%if 'totp' in errors:
<span class="error-message">${errors.get('totp')}</span>
<br />
%endif
</div>
<br />
${h.submit('save',_('Verify'),class_="btn btn-primary", style='width: 40%;')}
</div>
</div>
</p>
% endif
</div>
</div>
</div>
<div class="loginwrapper">
<rhodecode-toast id="notifications"></rhodecode-toast>
<div id="register">
<div class="sign-in-title">
<h1>${_('Verify the code from the app')}</h1>
</div>
<div class="inner form">
${h.secure_form(h.route_path('check_2fa'), request=request, id='totp_form')}
<label for="totp">${_('Verification code')}:</label>
${h.text('totp', class_="form-control")}
%if 'totp' in errors:
<span class="error-message">${errors.get('totp')}</span>
<br />
%endif
<p class="help-block">${_('Enter the code from your two-factor authenticator app. If you\'ve lost your device, you can enter one of your recovery codes.')}</p>
${h.submit('send', _('Verify'), class_="btn sign-in")}
<p class="help-block pull-right">
RhodeCode ${c.rhodecode_edition}
</p>
${h.end_form()}
</div>
</div>
</div>
</div>