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

@ -178,9 +178,7 @@ class BaseAppView(object):
if user_obj.has_forced_2fa and user_obj.extern_type != 'rhodecode':
return
if (user_obj.has_enabled_2fa
and not self.user_data.get('secret_2fa')) \
and view_name != self.SETUP_2FA_VIEW:
if user_obj.needs_2fa_configure and view_name != self.SETUP_2FA_VIEW:
h.flash(
"You are required to configure 2FA",
"warning",
@ -195,7 +193,7 @@ class BaseAppView(object):
if not user_obj:
return
if self.user_data.get('check_2fa') and view_name != self.VERIFY_2FA_VIEW:
if user_obj.has_check_2fa_flag and view_name != self.VERIFY_2FA_VIEW:
raise HTTPFound(self.request.route_path(self.VERIFY_2FA_VIEW))
def _log_creation_exception(self, e, repo_name):

View file

@ -33,7 +33,7 @@ class Test2FA(object):
def test_redirect_to_2fa_check_if_2fa_configured(self, user_util):
user = user_util.create_user(password=self.password)
user.has_enabled_2fa = True
user.secret_2fa
user.init_secret_2fa()
Session().add(user)
Session().commit()
self.app.post(
@ -47,8 +47,8 @@ class Test2FA(object):
def test_2fa_recovery_codes_works_only_once(self, user_util):
user = user_util.create_user(password=self.password)
user.has_enabled_2fa = True
user.secret_2fa
recovery_cod_to_check = user.get_2fa_recovery_codes()[0]
user.init_secret_2fa()
recovery_cod_to_check = user.init_2fa_recovery_codes()[0]
Session().add(user)
Session().commit()
self.app.post(

View file

@ -188,7 +188,8 @@ class LoginView(BaseAppView):
# form checks for username/password, now we're authenticated
username = form_result['username']
if (user := User.get_by_username_or_primary_email(username)).has_enabled_2fa:
user.update_userdata(check_2fa=True)
user.has_check_2fa_flag = True
headers = store_user_in_session(
self.session,
user_identifier=username,
@ -489,23 +490,32 @@ class LoginView(BaseAppView):
form = TOTPForm(_, user_instance)()
render_ctx = {}
if self.request.method == 'POST':
post_items = dict(self.request.POST)
try:
form.to_python(dict(self.request.POST))
form_details = form.to_python(post_items)
secret = form_details['secret_totp']
user_instance.init_2fa_recovery_codes(persist=True, force=True)
user_instance.set_2fa_secret(secret)
Session().commit()
raise HTTPFound(c.came_from)
raise HTTPFound(self.request.route_path('my_account_enable_2fa', _query={'show-recovery-codes': 1}))
except formencode.Invalid as errors:
defaults = errors.value
render_ctx = {
'errors': errors.error_dict,
'defaults': defaults,
}
# NOTE: here we DO NOT persist the secret 2FA, since this is only for setup, once a setup is completed
# only then we should persist it
secret = user_instance.init_secret_2fa(persist=False)
totp_name = f'RhodeCode token ({self.request.user.username})'
qr = qrcode.QRCode(version=1, box_size=10, border=5)
secret = user_instance.secret_2fa
Session().flush()
recovery_codes = user_instance.get_2fa_recovery_codes()
Session().commit()
qr.add_data(pyotp.totp.TOTP(secret).provisioning_uri(
name=self.request.user.name))
qr.add_data(pyotp.totp.TOTP(secret).provisioning_uri(name=totp_name))
qr.make(fit=True)
img = qr.make_image(fill_color='black', back_color='white')
buffered = BytesIO()
@ -513,8 +523,8 @@ class LoginView(BaseAppView):
return self._get_template_context(
c,
qr=b64encode(buffered.getvalue()).decode("utf-8"),
key=secret, recovery_codes=json.dumps(recovery_codes),
codes_viewed=not bool(recovery_codes),
key=secret,
totp_name=totp_name,
** render_ctx
)
@ -527,9 +537,12 @@ class LoginView(BaseAppView):
user_instance = self._rhodecode_db_user
totp_form = TOTPForm(_, user_instance, allow_recovery_code_use=True)()
if self.request.method == 'POST':
post_items = dict(self.request.POST)
# NOTE: inject secret, as it's a post configured saved item.
post_items['secret_totp'] = user_instance.get_secret_2fa()
try:
totp_form.to_python(dict(self.request.POST))
user_instance.update_userdata(check_2fa=False)
totp_form.to_python(post_items)
user_instance.has_check_2fa_flag = False
Session().commit()
raise HTTPFound(c.came_from)
except formencode.Invalid as errors:

View file

@ -83,24 +83,35 @@ def includeme(config):
attr='my_account_2fa',
route_name='my_account_enable_2fa', request_method='GET',
renderer='rhodecode:templates/admin/my_account/my_account.mako')
# my account 2fa save
config.add_route(
name='my_account_configure_2fa',
pattern=ADMIN_PREFIX + '/my_account/configure_2fa')
name='my_account_enable_2fa_save',
pattern=ADMIN_PREFIX + '/my_account/enable_2fa_save')
config.add_view(
MyAccountView,
attr='my_account_2fa_configure',
route_name='my_account_configure_2fa', request_method='POST', xhr=True,
attr='my_account_2fa_update',
route_name='my_account_enable_2fa_save', request_method='POST',
renderer='rhodecode:templates/admin/my_account/my_account.mako')
# my account 2fa recovery code-reset
config.add_route(
name='my_account_show_2fa_recovery_codes',
pattern=ADMIN_PREFIX + '/my_account/recovery_codes')
config.add_view(
MyAccountView,
attr='my_account_2fa_show_recovery_codes',
route_name='my_account_show_2fa_recovery_codes', request_method='POST', xhr=True,
renderer='json_ext')
# my account 2fa recovery code-reset
config.add_route(
name='my_account_regenerate_2fa_recovery_codes',
pattern=ADMIN_PREFIX + '/my_account/regenerate_recovery_codes')
config.add_view(
MyAccountView,
attr='my_account_2fa_regenerate_recovery_codes',
route_name='my_account_regenerate_2fa_recovery_codes', request_method='POST', xhr=True,
renderer='json_ext')
route_name='my_account_regenerate_2fa_recovery_codes', request_method='POST',
renderer='rhodecode:templates/admin/my_account/my_account.mako')
# my account tokens
config.add_route(

View file

@ -16,6 +16,7 @@
# RhodeCode Enterprise Edition, including its added features, Support services,
# and proprietary license terms, please see https://rhodecode.com/licenses/
import time
import logging
import datetime
import string
@ -43,6 +44,7 @@ from rhodecode.model.db import (
IntegrityError, or_, in_filter_generator, select,
Repository, UserEmailMap, UserApiKeys, UserFollowing,
PullRequest, UserBookmark, RepoGroup, ChangesetStatus)
from rhodecode.model.forms import TOTPForm
from rhodecode.model.meta import Session
from rhodecode.model.pull_request import PullRequestModel
from rhodecode.model.user import UserModel
@ -207,27 +209,65 @@ class MyAccountView(BaseAppView, DataGridAppView):
def my_account_2fa(self):
_ = self.request.translate
c = self.load_default_context()
c.active = '2fa'
from rhodecode.model.settings import SettingsModel
user_instance = self._rhodecode_db_user
c.active = '2FA'
user_instance = c.auth_user.get_instance()
locked_by_admin = user_instance.has_forced_2fa
c.state_of_2fa = user_instance.has_enabled_2fa
c.user_seen_2fa_recovery_codes = user_instance.has_seen_2fa_codes
c.locked_2fa = str2bool(locked_by_admin)
return self._get_template_context(c)
@LoginRequired()
@NotAnonymous()
@CSRFRequired()
def my_account_2fa_configure(self):
state = self.request.POST.get('state')
self._rhodecode_db_user.has_enabled_2fa = state
return {'state_of_2fa': state}
def my_account_2fa_update(self):
_ = self.request.translate
c = self.load_default_context()
c.active = '2FA'
user_instance = c.auth_user.get_instance()
state = self.request.POST.get('2fa_status') == '1'
user_instance.has_enabled_2fa = state
user_instance.update_userdata(update_2fa=time.time())
Session().commit()
h.flash(_("Successfully saved 2FA settings"), category='success')
raise HTTPFound(self.request.route_path('my_account_enable_2fa'))
@LoginRequired()
@NotAnonymous()
@CSRFRequired()
def my_account_2fa_show_recovery_codes(self):
c = self.load_default_context()
user_instance = c.auth_user.get_instance()
user_instance.has_seen_2fa_codes = True
Session().commit()
return {'recovery_codes': user_instance.get_2fa_recovery_codes()}
@LoginRequired()
@NotAnonymous()
@CSRFRequired()
def my_account_2fa_regenerate_recovery_codes(self):
return {'recovery_codes': self._rhodecode_db_user.regenerate_2fa_recovery_codes()}
_ = self.request.translate
c = self.load_default_context()
user_instance = c.auth_user.get_instance()
totp_form = TOTPForm(_, user_instance, allow_recovery_code_use=True)()
post_items = dict(self.request.POST)
# NOTE: inject secret, as it's a post configured saved item.
post_items['secret_totp'] = user_instance.get_secret_2fa()
try:
totp_form.to_python(post_items)
user_instance.regenerate_2fa_recovery_codes()
Session().commit()
except formencode.Invalid as errors:
h.flash(_("Failed to generate new recovery codes: {}").format(errors), category='error')
raise HTTPFound(self.request.route_path('my_account_enable_2fa'))
except Exception as e:
h.flash(_("Failed to generate new recovery codes: {}").format(e), category='error')
raise HTTPFound(self.request.route_path('my_account_enable_2fa'))
raise HTTPFound(self.request.route_path('my_account_enable_2fa', _query={'show-recovery-codes': 1}))
@LoginRequired()
@NotAnonymous()

View file

@ -796,34 +796,13 @@ class User(Base, BaseModel):
Session.commit()
return artifact_token.api_key
@hybrid_property
def secret_2fa(self):
if not self.user_data.get('secret_2fa'):
secret = pyotp.random_base32()
self.update_userdata(secret_2fa=safe_str(enc_utils.encrypt_value(secret, enc_key=ENCRYPTION_KEY)))
return secret
return safe_str(
enc_utils.decrypt_value(self.user_data['secret_2fa'],
enc_key=ENCRYPTION_KEY,
strict_mode=ConfigGet().get_bool('rhodecode.encrypted_values.strict',
missing=True)
)
)
def is_totp_valid(self, received_code):
totp = pyotp.TOTP(self.secret_2fa)
def is_totp_valid(self, received_code, secret):
totp = pyotp.TOTP(secret)
return totp.verify(received_code)
def is_2fa_recovery_code_valid(self, received_code):
def is_2fa_recovery_code_valid(self, received_code, secret):
encrypted_recovery_codes = self.user_data.get('recovery_codes_2fa', [])
recovery_codes = list(map(
lambda x: safe_str(
enc_utils.decrypt_value(
x,
enc_key=ENCRYPTION_KEY,
strict_mode=ConfigGet().get_bool('rhodecode.encrypted_values.strict', missing=True)
)),
encrypted_recovery_codes))
recovery_codes = self.get_2fa_recovery_codes()
if received_code in recovery_codes:
encrypted_recovery_codes.pop(recovery_codes.index(received_code))
self.update_userdata(recovery_codes_2fa=encrypted_recovery_codes)
@ -844,7 +823,7 @@ class User(Base, BaseModel):
@hybrid_property
def has_enabled_2fa(self):
"""
Checks if 2fa was enabled by user
Checks if user enabled 2fa
"""
if value := self.has_forced_2fa:
return value
@ -853,34 +832,109 @@ class User(Base, BaseModel):
@has_enabled_2fa.setter
def has_enabled_2fa(self, val):
val = str2bool(val)
self.update_userdata(enabled_2fa=str2bool(val))
self.update_userdata(enabled_2fa=val)
if not val:
self.update_userdata(secret_2fa=None, recovery_codes_2fa=[])
# NOTE: setting to false we clear the user_data to not store any 2fa artifacts
self.update_userdata(secret_2fa=None, recovery_codes_2fa=[], check_2fa=False)
Session().commit()
def get_2fa_recovery_codes(self):
@hybrid_property
def has_check_2fa_flag(self):
"""
Check if check 2fa flag is set for this user
"""
value = self.user_data.get('check_2fa', False)
return value
@has_check_2fa_flag.setter
def has_check_2fa_flag(self, val):
val = str2bool(val)
self.update_userdata(check_2fa=val)
Session().commit()
@hybrid_property
def has_seen_2fa_codes(self):
"""
get the flag about if user has seen 2fa recovery codes
"""
value = self.user_data.get('recovery_codes_2fa_seen', False)
return value
@has_seen_2fa_codes.setter
def has_seen_2fa_codes(self, val):
val = str2bool(val)
self.update_userdata(recovery_codes_2fa_seen=val)
Session().commit()
@hybrid_property
def needs_2fa_configure(self):
"""
Determines if setup2fa has completed for this user. Means he has all needed data for 2fa to work.
Currently this is 2fa enabled and secret exists
"""
if self.has_enabled_2fa:
return not self.user_data.get('secret_2fa')
return False
def init_2fa_recovery_codes(self, persist=True, force=False):
"""
Creates 2fa recovery codes
"""
recovery_codes = self.user_data.get('recovery_codes_2fa', [])
encrypted_codes = []
if not recovery_codes:
if not recovery_codes or force:
for _ in range(self.RECOVERY_CODES_COUNT):
recovery_code = pyotp.random_base32()
recovery_codes.append(recovery_code)
encrypted_codes.append(safe_str(enc_utils.encrypt_value(recovery_code, enc_key=ENCRYPTION_KEY)))
self.update_userdata(recovery_codes_2fa=encrypted_codes)
encrypted_code = enc_utils.encrypt_value(safe_bytes(recovery_code), enc_key=ENCRYPTION_KEY)
encrypted_codes.append(safe_str(encrypted_code))
if persist:
self.update_userdata(recovery_codes_2fa=encrypted_codes, recovery_codes_2fa_seen=False)
return recovery_codes
# User should not check the same recovery codes more than once
return []
def get_2fa_recovery_codes(self):
encrypted_recovery_codes = self.user_data.get('recovery_codes_2fa', [])
strict_mode = ConfigGet().get_bool('rhodecode.encrypted_values.strict', missing=True)
recovery_codes = list(map(
lambda val: safe_str(
enc_utils.decrypt_value(
val,
enc_key=ENCRYPTION_KEY,
strict_mode=strict_mode
)),
encrypted_recovery_codes))
return recovery_codes
def init_secret_2fa(self, persist=True, force=False):
secret_2fa = self.user_data.get('secret_2fa')
if not secret_2fa or force:
secret = pyotp.random_base32()
if persist:
self.update_userdata(secret_2fa=safe_str(enc_utils.encrypt_value(safe_bytes(secret), enc_key=ENCRYPTION_KEY)))
return secret
return ''
def get_secret_2fa(self) -> str:
secret_2fa = self.user_data['secret_2fa']
if secret_2fa:
strict_mode = ConfigGet().get_bool('rhodecode.encrypted_values.strict', missing=True)
return safe_str(
enc_utils.decrypt_value(secret_2fa, enc_key=ENCRYPTION_KEY, strict_mode=strict_mode))
return ''
def set_2fa_secret(self, value):
encrypted_value = enc_utils.encrypt_value(safe_bytes(value), enc_key=ENCRYPTION_KEY)
self.update_userdata(secret_2fa=safe_str(encrypted_value))
def regenerate_2fa_recovery_codes(self):
"""
Regenerates 2fa recovery codes upon request
"""
self.update_userdata(recovery_codes_2fa=[])
Session().flush()
new_recovery_codes = self.get_2fa_recovery_codes()
new_recovery_codes = self.init_2fa_recovery_codes(force=True)
Session().commit()
return new_recovery_codes
@ -5021,8 +5075,7 @@ class Gist(Base, BaseModel):
return data
def __json__(self):
data = dict(
)
data = dict()
data.update(self.get_api_data())
return data
# SCM functions

View file

@ -111,6 +111,7 @@ def TOTPForm(localizer, user, allow_recovery_code_use=False):
allow_extra_fields = True
filter_extra_fields = False
totp = v.Regex(r'^(?:\d{6}|[A-Z0-9]{32})$')
secret_totp = v.String()
def to_python(self, value, state=None):
validation_checks = [user.is_totp_valid]
@ -118,10 +119,12 @@ def TOTPForm(localizer, user, allow_recovery_code_use=False):
validation_checks.append(user.is_2fa_recovery_code_valid)
form_data = super().to_python(value, state)
received_code = form_data['totp']
if not any(map(lambda x: x(received_code), validation_checks)):
secret = form_data.get('secret_totp')
if not any(map(lambda func: func(received_code, secret), validation_checks)):
error_msg = _('Code is invalid. Try again!')
raise formencode.Invalid(error_msg, v, state, error_dict={'totp': error_msg})
return True
return form_data
return _TOTPForm

View file

@ -95,6 +95,7 @@ function registerRCRoutes() {
pyroutes.register('channelstream_connect', '/_admin/channelstream/connect', []);
pyroutes.register('channelstream_proxy', '/_channelstream', []);
pyroutes.register('channelstream_subscribe', '/_admin/channelstream/subscribe', []);
pyroutes.register('check_2fa', '/_admin/check_2fa', []);
pyroutes.register('commit_draft_comments_submit', '/%(repo_name)s/changeset/%(commit_id)s/draft_comments_submit', ['repo_name', 'commit_id']);
pyroutes.register('debug_style_email', '/_admin/debug_style/email/%(email_id)s', ['email_id']);
pyroutes.register('debug_style_email_plain_rendered', '/_admin/debug_style/email-rendered/%(email_id)s', ['email_id']);
@ -218,22 +219,23 @@ function registerRCRoutes() {
pyroutes.register('my_account_emails', '/_admin/my_account/emails', []);
pyroutes.register('my_account_emails_add', '/_admin/my_account/emails/new', []);
pyroutes.register('my_account_emails_delete', '/_admin/my_account/emails/delete', []);
pyroutes.register('my_account_enable_2fa', '/_admin/my_account/enable_2fa', []);
pyroutes.register('my_account_enable_2fa_save', '/_admin/my_account/enable_2fa_save', []);
pyroutes.register('my_account_external_identity', '/_admin/my_account/external-identity', []);
pyroutes.register('my_account_external_identity_delete', '/_admin/my_account/external-identity/delete', []);
pyroutes.register('my_account_goto_bookmark', '/_admin/my_account/bookmark/%(bookmark_id)s', ['bookmark_id']);
pyroutes.register('my_account_notifications', '/_admin/my_account/notifications', []);
pyroutes.register('my_account_notifications_test_channelstream', '/_admin/my_account/test_channelstream', []);
pyroutes.register('my_account_notifications_toggle_visibility', '/_admin/my_account/toggle_visibility', []);
pyroutes.register('check_2fa', '/_admin/check_2fa', []);
pyroutes.register('my_account_configure_2fa', '/_admin/my_account/configure_2fa', []);
pyroutes.register('my_account_regenerate_2fa_recovery_codes', '/_admin/my_account/regenerate_recovery_codes', []);
pyroutes.register('my_account_password', '/_admin/my_account/password', []);
pyroutes.register('my_account_password_update', '/_admin/my_account/password/update', []);
pyroutes.register('my_account_perms', '/_admin/my_account/perms', []);
pyroutes.register('my_account_profile', '/_admin/my_account/profile', []);
pyroutes.register('my_account_pullrequests', '/_admin/my_account/pull_requests', []);
pyroutes.register('my_account_pullrequests_data', '/_admin/my_account/pull_requests/data', []);
pyroutes.register('my_account_regenerate_2fa_recovery_codes', '/_admin/my_account/regenerate_recovery_codes', []);
pyroutes.register('my_account_repos', '/_admin/my_account/repos', []);
pyroutes.register('my_account_show_2fa_recovery_codes', '/_admin/my_account/recovery_codes', []);
pyroutes.register('my_account_ssh_keys', '/_admin/my_account/ssh_keys', []);
pyroutes.register('my_account_ssh_keys_add', '/_admin/my_account/ssh_keys/new', []);
pyroutes.register('my_account_ssh_keys_delete', '/_admin/my_account/ssh_keys/delete', []);
@ -382,6 +384,7 @@ function registerRCRoutes() {
pyroutes.register('search_repo', '/%(repo_name)s/_search', ['repo_name']);
pyroutes.register('search_repo_alt', '/%(repo_name)s/search', ['repo_name']);
pyroutes.register('search_repo_group', '/%(repo_group_name)s/_search', ['repo_group_name']);
pyroutes.register('setup_2fa', '/_admin/setup_2fa', []);
pyroutes.register('store_user_session_value', '/_store_session_attr', []);
pyroutes.register('strip_check', '/%(repo_name)s/settings/strip_check', ['repo_name']);
pyroutes.register('strip_execute', '/%(repo_name)s/settings/strip_execute', ['repo_name']);

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>