# HG changeset patch # User RhodeCode Admin # Date 2024-04-24 07:45:36 # Node ID a11e6ff3aee8d300efa6b8844ce7b105ac639814 # Parent 33b6c4866c6f3a653d64232d7476aa0cea67427d 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 diff --git a/rhodecode/apps/_base/__init__.py b/rhodecode/apps/_base/__init__.py --- a/rhodecode/apps/_base/__init__.py +++ b/rhodecode/apps/_base/__init__.py @@ -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): diff --git a/rhodecode/apps/login/tests/test_2fa.py b/rhodecode/apps/login/tests/test_2fa.py --- a/rhodecode/apps/login/tests/test_2fa.py +++ b/rhodecode/apps/login/tests/test_2fa.py @@ -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( diff --git a/rhodecode/apps/login/views.py b/rhodecode/apps/login/views.py --- a/rhodecode/apps/login/views.py +++ b/rhodecode/apps/login/views.py @@ -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: diff --git a/rhodecode/apps/my_account/__init__.py b/rhodecode/apps/my_account/__init__.py --- a/rhodecode/apps/my_account/__init__.py +++ b/rhodecode/apps/my_account/__init__.py @@ -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( diff --git a/rhodecode/apps/my_account/views/my_account.py b/rhodecode/apps/my_account/views/my_account.py --- a/rhodecode/apps/my_account/views/my_account.py +++ b/rhodecode/apps/my_account/views/my_account.py @@ -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, DataGri 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() diff --git a/rhodecode/model/db.py b/rhodecode/model/db.py --- a/rhodecode/model/db.py +++ b/rhodecode/model/db.py @@ -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() + + @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() - def get_2fa_recovery_codes(self): + @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 diff --git a/rhodecode/model/forms.py b/rhodecode/model/forms.py --- a/rhodecode/model/forms.py +++ b/rhodecode/model/forms.py @@ -111,6 +111,7 @@ def TOTPForm(localizer, user, allow_reco 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_reco 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 diff --git a/rhodecode/public/js/rhodecode/routes.js b/rhodecode/public/js/rhodecode/routes.js --- a/rhodecode/public/js/rhodecode/routes.js +++ b/rhodecode/public/js/rhodecode/routes.js @@ -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']); diff --git a/rhodecode/templates/admin/my_account/my_account_2fa.mako b/rhodecode/templates/admin/my_account/my_account_2fa.mako --- a/rhodecode/templates/admin/my_account/my_account_2fa.mako +++ b/rhodecode/templates/admin/my_account/my_account_2fa.mako @@ -4,6 +4,7 @@

${_('Enable/Disable 2FA for your account')}

+ ${h.secure_form(h.route_path('my_account_enable_2fa_save'), request=request)}
@@ -12,112 +13,122 @@
- -
- - -
% if c.locked_2fa: ${_('2FA settings cannot be changed here, because 2FA was forced enabled by RhodeCode Administrator.')} + + % else: +
+ + + + + +
% endif +
+ ${h.end_form()} % if c.state_of_2fa: + + +% if not c.user_seen_2fa_recovery_codes: + +
+
+

${_('2FA Recovery codes')}

+
+
+

+ ${_('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.')} +

+
+ ${_('Show recovery codes')} +
+
+% endif + + +${h.secure_form(h.route_path('my_account_regenerate_2fa_recovery_codes'), request=request)}

${_('Regenerate 2FA recovery codes for your account')}

- - + +
-
+${h.end_form()} +% endif -% endif diff --git a/rhodecode/templates/configure_2fa.mako b/rhodecode/templates/configure_2fa.mako --- a/rhodecode/templates/configure_2fa.mako +++ b/rhodecode/templates/configure_2fa.mako @@ -1,7 +1,7 @@ <%inherit file="base/root.mako"/> <%def name="title()"> - ${_('Setup authenticator app')} + ${_('Setup 2FA')} %if c.rhodecode_name: · ${h.branding(c.rhodecode_name)} %endif @@ -22,31 +22,28 @@
-

Setup the authenticator app

+

${_('Setup the authenticator app')}

+

Authenticator apps like Google Authenticator, etc. generate one-time passwords that are used as a second factor to verify you identity.

+ ${h.secure_form(h.route_path('setup_2fa'), request=request, id='totp_form')} -

Use an authenticator app to scan.

- +

${_('Use an authenticator app to scan.')}

+ qr-code +

${_('Unable to scan?')} ${_('Click here')}

- -

+
- ${h.secure_form(h.route_path('setup_2fa'), request=request, id='totp_form')} +

@@ -59,13 +56,13 @@

${h.text('totp', class_='form-control', style='width: 40%;')}
- %if 'totp' in errors: + % if 'totp' in errors: ${errors.get('totp')}
- %endif + % endif
- ${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%;')}
@@ -73,81 +70,18 @@
+ ${h.end_form()}
- - diff --git a/rhodecode/templates/verify_2fa.mako b/rhodecode/templates/verify_2fa.mako --- a/rhodecode/templates/verify_2fa.mako +++ b/rhodecode/templates/verify_2fa.mako @@ -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: · ${h.branding(c.rhodecode_name)} %endif + -
-
- ${h.secure_form(h.route_path('check_2fa'), request=request, id='totp_form')} -
-
-

-

- +
+ + +
+ + +
+ +
+ ${h.secure_form(h.route_path('check_2fa'), request=request, id='totp_form')} + + ${h.text('totp', class_="form-control")} + %if 'totp' in errors: + ${errors.get('totp')} +
+ %endif +

${_('Enter the code from your two-factor authenticator app. If you\'ve lost your device, you can enter one of your recovery codes.')}

+ + ${h.submit('send', _('Verify'), class_="btn sign-in")} +

+ RhodeCode ${c.rhodecode_edition} +

+ ${h.end_form()} +
+
+ +
+ + + +