##// END OF EJS Templates
feat(2fa): refactor logic arround validation/recoverycodes and workflows of configuration of 2fa...
super-admin -
r5367:a11e6ff3 default
parent child Browse files
Show More
@@ -178,9 +178,7 b' class BaseAppView(object):'
178 if user_obj.has_forced_2fa and user_obj.extern_type != 'rhodecode':
178 if user_obj.has_forced_2fa and user_obj.extern_type != 'rhodecode':
179 return
179 return
180
180
181 if (user_obj.has_enabled_2fa
181 if user_obj.needs_2fa_configure and view_name != self.SETUP_2FA_VIEW:
182 and not self.user_data.get('secret_2fa')) \
183 and view_name != self.SETUP_2FA_VIEW:
184 h.flash(
182 h.flash(
185 "You are required to configure 2FA",
183 "You are required to configure 2FA",
186 "warning",
184 "warning",
@@ -195,7 +193,7 b' class BaseAppView(object):'
195 if not user_obj:
193 if not user_obj:
196 return
194 return
197
195
198 if self.user_data.get('check_2fa') and view_name != self.VERIFY_2FA_VIEW:
196 if user_obj.has_check_2fa_flag and view_name != self.VERIFY_2FA_VIEW:
199 raise HTTPFound(self.request.route_path(self.VERIFY_2FA_VIEW))
197 raise HTTPFound(self.request.route_path(self.VERIFY_2FA_VIEW))
200
198
201 def _log_creation_exception(self, e, repo_name):
199 def _log_creation_exception(self, e, repo_name):
@@ -33,7 +33,7 b' class Test2FA(object):'
33 def test_redirect_to_2fa_check_if_2fa_configured(self, user_util):
33 def test_redirect_to_2fa_check_if_2fa_configured(self, user_util):
34 user = user_util.create_user(password=self.password)
34 user = user_util.create_user(password=self.password)
35 user.has_enabled_2fa = True
35 user.has_enabled_2fa = True
36 user.secret_2fa
36 user.init_secret_2fa()
37 Session().add(user)
37 Session().add(user)
38 Session().commit()
38 Session().commit()
39 self.app.post(
39 self.app.post(
@@ -47,8 +47,8 b' class Test2FA(object):'
47 def test_2fa_recovery_codes_works_only_once(self, user_util):
47 def test_2fa_recovery_codes_works_only_once(self, user_util):
48 user = user_util.create_user(password=self.password)
48 user = user_util.create_user(password=self.password)
49 user.has_enabled_2fa = True
49 user.has_enabled_2fa = True
50 user.secret_2fa
50 user.init_secret_2fa()
51 recovery_cod_to_check = user.get_2fa_recovery_codes()[0]
51 recovery_cod_to_check = user.init_2fa_recovery_codes()[0]
52 Session().add(user)
52 Session().add(user)
53 Session().commit()
53 Session().commit()
54 self.app.post(
54 self.app.post(
@@ -188,7 +188,8 b' class LoginView(BaseAppView):'
188 # form checks for username/password, now we're authenticated
188 # form checks for username/password, now we're authenticated
189 username = form_result['username']
189 username = form_result['username']
190 if (user := User.get_by_username_or_primary_email(username)).has_enabled_2fa:
190 if (user := User.get_by_username_or_primary_email(username)).has_enabled_2fa:
191 user.update_userdata(check_2fa=True)
191 user.has_check_2fa_flag = True
192
192 headers = store_user_in_session(
193 headers = store_user_in_session(
193 self.session,
194 self.session,
194 user_identifier=username,
195 user_identifier=username,
@@ -489,23 +490,32 b' class LoginView(BaseAppView):'
489 form = TOTPForm(_, user_instance)()
490 form = TOTPForm(_, user_instance)()
490 render_ctx = {}
491 render_ctx = {}
491 if self.request.method == 'POST':
492 if self.request.method == 'POST':
493 post_items = dict(self.request.POST)
494
492 try:
495 try:
493 form.to_python(dict(self.request.POST))
496 form_details = form.to_python(post_items)
497 secret = form_details['secret_totp']
498
499 user_instance.init_2fa_recovery_codes(persist=True, force=True)
500 user_instance.set_2fa_secret(secret)
501
494 Session().commit()
502 Session().commit()
495 raise HTTPFound(c.came_from)
503 raise HTTPFound(self.request.route_path('my_account_enable_2fa', _query={'show-recovery-codes': 1}))
496 except formencode.Invalid as errors:
504 except formencode.Invalid as errors:
497 defaults = errors.value
505 defaults = errors.value
498 render_ctx = {
506 render_ctx = {
499 'errors': errors.error_dict,
507 'errors': errors.error_dict,
500 'defaults': defaults,
508 'defaults': defaults,
501 }
509 }
510
511 # NOTE: here we DO NOT persist the secret 2FA, since this is only for setup, once a setup is completed
512 # only then we should persist it
513 secret = user_instance.init_secret_2fa(persist=False)
514
515 totp_name = f'RhodeCode token ({self.request.user.username})'
516
502 qr = qrcode.QRCode(version=1, box_size=10, border=5)
517 qr = qrcode.QRCode(version=1, box_size=10, border=5)
503 secret = user_instance.secret_2fa
518 qr.add_data(pyotp.totp.TOTP(secret).provisioning_uri(name=totp_name))
504 Session().flush()
505 recovery_codes = user_instance.get_2fa_recovery_codes()
506 Session().commit()
507 qr.add_data(pyotp.totp.TOTP(secret).provisioning_uri(
508 name=self.request.user.name))
509 qr.make(fit=True)
519 qr.make(fit=True)
510 img = qr.make_image(fill_color='black', back_color='white')
520 img = qr.make_image(fill_color='black', back_color='white')
511 buffered = BytesIO()
521 buffered = BytesIO()
@@ -513,8 +523,8 b' class LoginView(BaseAppView):'
513 return self._get_template_context(
523 return self._get_template_context(
514 c,
524 c,
515 qr=b64encode(buffered.getvalue()).decode("utf-8"),
525 qr=b64encode(buffered.getvalue()).decode("utf-8"),
516 key=secret, recovery_codes=json.dumps(recovery_codes),
526 key=secret,
517 codes_viewed=not bool(recovery_codes),
527 totp_name=totp_name,
518 ** render_ctx
528 ** render_ctx
519 )
529 )
520
530
@@ -527,9 +537,12 b' class LoginView(BaseAppView):'
527 user_instance = self._rhodecode_db_user
537 user_instance = self._rhodecode_db_user
528 totp_form = TOTPForm(_, user_instance, allow_recovery_code_use=True)()
538 totp_form = TOTPForm(_, user_instance, allow_recovery_code_use=True)()
529 if self.request.method == 'POST':
539 if self.request.method == 'POST':
540 post_items = dict(self.request.POST)
541 # NOTE: inject secret, as it's a post configured saved item.
542 post_items['secret_totp'] = user_instance.get_secret_2fa()
530 try:
543 try:
531 totp_form.to_python(dict(self.request.POST))
544 totp_form.to_python(post_items)
532 user_instance.update_userdata(check_2fa=False)
545 user_instance.has_check_2fa_flag = False
533 Session().commit()
546 Session().commit()
534 raise HTTPFound(c.came_from)
547 raise HTTPFound(c.came_from)
535 except formencode.Invalid as errors:
548 except formencode.Invalid as errors:
@@ -83,24 +83,35 b' def includeme(config):'
83 attr='my_account_2fa',
83 attr='my_account_2fa',
84 route_name='my_account_enable_2fa', request_method='GET',
84 route_name='my_account_enable_2fa', request_method='GET',
85 renderer='rhodecode:templates/admin/my_account/my_account.mako')
85 renderer='rhodecode:templates/admin/my_account/my_account.mako')
86
86 # my account 2fa save
87 config.add_route(
87 config.add_route(
88 name='my_account_configure_2fa',
88 name='my_account_enable_2fa_save',
89 pattern=ADMIN_PREFIX + '/my_account/configure_2fa')
89 pattern=ADMIN_PREFIX + '/my_account/enable_2fa_save')
90 config.add_view(
90 config.add_view(
91 MyAccountView,
91 MyAccountView,
92 attr='my_account_2fa_configure',
92 attr='my_account_2fa_update',
93 route_name='my_account_configure_2fa', request_method='POST', xhr=True,
93 route_name='my_account_enable_2fa_save', request_method='POST',
94 renderer='rhodecode:templates/admin/my_account/my_account.mako')
95
96 # my account 2fa recovery code-reset
97 config.add_route(
98 name='my_account_show_2fa_recovery_codes',
99 pattern=ADMIN_PREFIX + '/my_account/recovery_codes')
100 config.add_view(
101 MyAccountView,
102 attr='my_account_2fa_show_recovery_codes',
103 route_name='my_account_show_2fa_recovery_codes', request_method='POST', xhr=True,
94 renderer='json_ext')
104 renderer='json_ext')
95
105
106 # my account 2fa recovery code-reset
96 config.add_route(
107 config.add_route(
97 name='my_account_regenerate_2fa_recovery_codes',
108 name='my_account_regenerate_2fa_recovery_codes',
98 pattern=ADMIN_PREFIX + '/my_account/regenerate_recovery_codes')
109 pattern=ADMIN_PREFIX + '/my_account/regenerate_recovery_codes')
99 config.add_view(
110 config.add_view(
100 MyAccountView,
111 MyAccountView,
101 attr='my_account_2fa_regenerate_recovery_codes',
112 attr='my_account_2fa_regenerate_recovery_codes',
102 route_name='my_account_regenerate_2fa_recovery_codes', request_method='POST', xhr=True,
113 route_name='my_account_regenerate_2fa_recovery_codes', request_method='POST',
103 renderer='json_ext')
114 renderer='rhodecode:templates/admin/my_account/my_account.mako')
104
115
105 # my account tokens
116 # my account tokens
106 config.add_route(
117 config.add_route(
@@ -16,6 +16,7 b''
16 # RhodeCode Enterprise Edition, including its added features, Support services,
16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 # and proprietary license terms, please see https://rhodecode.com/licenses/
17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18
18
19 import time
19 import logging
20 import logging
20 import datetime
21 import datetime
21 import string
22 import string
@@ -43,6 +44,7 b' from rhodecode.model.db import ('
43 IntegrityError, or_, in_filter_generator, select,
44 IntegrityError, or_, in_filter_generator, select,
44 Repository, UserEmailMap, UserApiKeys, UserFollowing,
45 Repository, UserEmailMap, UserApiKeys, UserFollowing,
45 PullRequest, UserBookmark, RepoGroup, ChangesetStatus)
46 PullRequest, UserBookmark, RepoGroup, ChangesetStatus)
47 from rhodecode.model.forms import TOTPForm
46 from rhodecode.model.meta import Session
48 from rhodecode.model.meta import Session
47 from rhodecode.model.pull_request import PullRequestModel
49 from rhodecode.model.pull_request import PullRequestModel
48 from rhodecode.model.user import UserModel
50 from rhodecode.model.user import UserModel
@@ -207,27 +209,65 b' class MyAccountView(BaseAppView, DataGri'
207 def my_account_2fa(self):
209 def my_account_2fa(self):
208 _ = self.request.translate
210 _ = self.request.translate
209 c = self.load_default_context()
211 c = self.load_default_context()
210 c.active = '2fa'
212 c.active = '2FA'
211 from rhodecode.model.settings import SettingsModel
213 user_instance = c.auth_user.get_instance()
212 user_instance = self._rhodecode_db_user
213 locked_by_admin = user_instance.has_forced_2fa
214 locked_by_admin = user_instance.has_forced_2fa
214 c.state_of_2fa = user_instance.has_enabled_2fa
215 c.state_of_2fa = user_instance.has_enabled_2fa
216 c.user_seen_2fa_recovery_codes = user_instance.has_seen_2fa_codes
215 c.locked_2fa = str2bool(locked_by_admin)
217 c.locked_2fa = str2bool(locked_by_admin)
216 return self._get_template_context(c)
218 return self._get_template_context(c)
217
219
218 @LoginRequired()
220 @LoginRequired()
219 @NotAnonymous()
221 @NotAnonymous()
220 @CSRFRequired()
222 @CSRFRequired()
221 def my_account_2fa_configure(self):
223 def my_account_2fa_update(self):
222 state = self.request.POST.get('state')
224 _ = self.request.translate
223 self._rhodecode_db_user.has_enabled_2fa = state
225 c = self.load_default_context()
224 return {'state_of_2fa': state}
226 c.active = '2FA'
227 user_instance = c.auth_user.get_instance()
228
229 state = self.request.POST.get('2fa_status') == '1'
230 user_instance.has_enabled_2fa = state
231 user_instance.update_userdata(update_2fa=time.time())
232 Session().commit()
233 h.flash(_("Successfully saved 2FA settings"), category='success')
234 raise HTTPFound(self.request.route_path('my_account_enable_2fa'))
235
236 @LoginRequired()
237 @NotAnonymous()
238 @CSRFRequired()
239 def my_account_2fa_show_recovery_codes(self):
240 c = self.load_default_context()
241 user_instance = c.auth_user.get_instance()
242 user_instance.has_seen_2fa_codes = True
243 Session().commit()
244 return {'recovery_codes': user_instance.get_2fa_recovery_codes()}
225
245
226 @LoginRequired()
246 @LoginRequired()
227 @NotAnonymous()
247 @NotAnonymous()
228 @CSRFRequired()
248 @CSRFRequired()
229 def my_account_2fa_regenerate_recovery_codes(self):
249 def my_account_2fa_regenerate_recovery_codes(self):
230 return {'recovery_codes': self._rhodecode_db_user.regenerate_2fa_recovery_codes()}
250 _ = self.request.translate
251 c = self.load_default_context()
252 user_instance = c.auth_user.get_instance()
253
254 totp_form = TOTPForm(_, user_instance, allow_recovery_code_use=True)()
255
256 post_items = dict(self.request.POST)
257 # NOTE: inject secret, as it's a post configured saved item.
258 post_items['secret_totp'] = user_instance.get_secret_2fa()
259 try:
260 totp_form.to_python(post_items)
261 user_instance.regenerate_2fa_recovery_codes()
262 Session().commit()
263 except formencode.Invalid as errors:
264 h.flash(_("Failed to generate new recovery codes: {}").format(errors), category='error')
265 raise HTTPFound(self.request.route_path('my_account_enable_2fa'))
266 except Exception as e:
267 h.flash(_("Failed to generate new recovery codes: {}").format(e), category='error')
268 raise HTTPFound(self.request.route_path('my_account_enable_2fa'))
269
270 raise HTTPFound(self.request.route_path('my_account_enable_2fa', _query={'show-recovery-codes': 1}))
231
271
232 @LoginRequired()
272 @LoginRequired()
233 @NotAnonymous()
273 @NotAnonymous()
@@ -796,34 +796,13 b' class User(Base, BaseModel):'
796 Session.commit()
796 Session.commit()
797 return artifact_token.api_key
797 return artifact_token.api_key
798
798
799 @hybrid_property
799 def is_totp_valid(self, received_code, secret):
800 def secret_2fa(self):
800 totp = pyotp.TOTP(secret)
801 if not self.user_data.get('secret_2fa'):
802 secret = pyotp.random_base32()
803 self.update_userdata(secret_2fa=safe_str(enc_utils.encrypt_value(secret, enc_key=ENCRYPTION_KEY)))
804 return secret
805 return safe_str(
806 enc_utils.decrypt_value(self.user_data['secret_2fa'],
807 enc_key=ENCRYPTION_KEY,
808 strict_mode=ConfigGet().get_bool('rhodecode.encrypted_values.strict',
809 missing=True)
810 )
811 )
812
813 def is_totp_valid(self, received_code):
814 totp = pyotp.TOTP(self.secret_2fa)
815 return totp.verify(received_code)
801 return totp.verify(received_code)
816
802
817 def is_2fa_recovery_code_valid(self, received_code):
803 def is_2fa_recovery_code_valid(self, received_code, secret):
818 encrypted_recovery_codes = self.user_data.get('recovery_codes_2fa', [])
804 encrypted_recovery_codes = self.user_data.get('recovery_codes_2fa', [])
819 recovery_codes = list(map(
805 recovery_codes = self.get_2fa_recovery_codes()
820 lambda x: safe_str(
821 enc_utils.decrypt_value(
822 x,
823 enc_key=ENCRYPTION_KEY,
824 strict_mode=ConfigGet().get_bool('rhodecode.encrypted_values.strict', missing=True)
825 )),
826 encrypted_recovery_codes))
827 if received_code in recovery_codes:
806 if received_code in recovery_codes:
828 encrypted_recovery_codes.pop(recovery_codes.index(received_code))
807 encrypted_recovery_codes.pop(recovery_codes.index(received_code))
829 self.update_userdata(recovery_codes_2fa=encrypted_recovery_codes)
808 self.update_userdata(recovery_codes_2fa=encrypted_recovery_codes)
@@ -844,7 +823,7 b' class User(Base, BaseModel):'
844 @hybrid_property
823 @hybrid_property
845 def has_enabled_2fa(self):
824 def has_enabled_2fa(self):
846 """
825 """
847 Checks if 2fa was enabled by user
826 Checks if user enabled 2fa
848 """
827 """
849 if value := self.has_forced_2fa:
828 if value := self.has_forced_2fa:
850 return value
829 return value
@@ -853,34 +832,109 b' class User(Base, BaseModel):'
853 @has_enabled_2fa.setter
832 @has_enabled_2fa.setter
854 def has_enabled_2fa(self, val):
833 def has_enabled_2fa(self, val):
855 val = str2bool(val)
834 val = str2bool(val)
856 self.update_userdata(enabled_2fa=str2bool(val))
835 self.update_userdata(enabled_2fa=val)
857 if not val:
836 if not val:
858 self.update_userdata(secret_2fa=None, recovery_codes_2fa=[])
837 # NOTE: setting to false we clear the user_data to not store any 2fa artifacts
838 self.update_userdata(secret_2fa=None, recovery_codes_2fa=[], check_2fa=False)
839 Session().commit()
840
841 @hybrid_property
842 def has_check_2fa_flag(self):
843 """
844 Check if check 2fa flag is set for this user
845 """
846 value = self.user_data.get('check_2fa', False)
847 return value
848
849 @has_check_2fa_flag.setter
850 def has_check_2fa_flag(self, val):
851 val = str2bool(val)
852 self.update_userdata(check_2fa=val)
859 Session().commit()
853 Session().commit()
860
854
861 def get_2fa_recovery_codes(self):
855 @hybrid_property
856 def has_seen_2fa_codes(self):
857 """
858 get the flag about if user has seen 2fa recovery codes
859 """
860 value = self.user_data.get('recovery_codes_2fa_seen', False)
861 return value
862
863 @has_seen_2fa_codes.setter
864 def has_seen_2fa_codes(self, val):
865 val = str2bool(val)
866 self.update_userdata(recovery_codes_2fa_seen=val)
867 Session().commit()
868
869 @hybrid_property
870 def needs_2fa_configure(self):
871 """
872 Determines if setup2fa has completed for this user. Means he has all needed data for 2fa to work.
873
874 Currently this is 2fa enabled and secret exists
875 """
876 if self.has_enabled_2fa:
877 return not self.user_data.get('secret_2fa')
878 return False
879
880 def init_2fa_recovery_codes(self, persist=True, force=False):
862 """
881 """
863 Creates 2fa recovery codes
882 Creates 2fa recovery codes
864 """
883 """
865 recovery_codes = self.user_data.get('recovery_codes_2fa', [])
884 recovery_codes = self.user_data.get('recovery_codes_2fa', [])
866 encrypted_codes = []
885 encrypted_codes = []
867 if not recovery_codes:
886 if not recovery_codes or force:
868 for _ in range(self.RECOVERY_CODES_COUNT):
887 for _ in range(self.RECOVERY_CODES_COUNT):
869 recovery_code = pyotp.random_base32()
888 recovery_code = pyotp.random_base32()
870 recovery_codes.append(recovery_code)
889 recovery_codes.append(recovery_code)
871 encrypted_codes.append(safe_str(enc_utils.encrypt_value(recovery_code, enc_key=ENCRYPTION_KEY)))
890 encrypted_code = enc_utils.encrypt_value(safe_bytes(recovery_code), enc_key=ENCRYPTION_KEY)
872 self.update_userdata(recovery_codes_2fa=encrypted_codes)
891 encrypted_codes.append(safe_str(encrypted_code))
892 if persist:
893 self.update_userdata(recovery_codes_2fa=encrypted_codes, recovery_codes_2fa_seen=False)
873 return recovery_codes
894 return recovery_codes
874 # User should not check the same recovery codes more than once
895 # User should not check the same recovery codes more than once
875 return []
896 return []
876
897
898 def get_2fa_recovery_codes(self):
899 encrypted_recovery_codes = self.user_data.get('recovery_codes_2fa', [])
900 strict_mode = ConfigGet().get_bool('rhodecode.encrypted_values.strict', missing=True)
901
902 recovery_codes = list(map(
903 lambda val: safe_str(
904 enc_utils.decrypt_value(
905 val,
906 enc_key=ENCRYPTION_KEY,
907 strict_mode=strict_mode
908 )),
909 encrypted_recovery_codes))
910 return recovery_codes
911
912 def init_secret_2fa(self, persist=True, force=False):
913 secret_2fa = self.user_data.get('secret_2fa')
914 if not secret_2fa or force:
915 secret = pyotp.random_base32()
916 if persist:
917 self.update_userdata(secret_2fa=safe_str(enc_utils.encrypt_value(safe_bytes(secret), enc_key=ENCRYPTION_KEY)))
918 return secret
919 return ''
920
921 def get_secret_2fa(self) -> str:
922 secret_2fa = self.user_data['secret_2fa']
923 if secret_2fa:
924 strict_mode = ConfigGet().get_bool('rhodecode.encrypted_values.strict', missing=True)
925 return safe_str(
926 enc_utils.decrypt_value(secret_2fa, enc_key=ENCRYPTION_KEY, strict_mode=strict_mode))
927 return ''
928
929 def set_2fa_secret(self, value):
930 encrypted_value = enc_utils.encrypt_value(safe_bytes(value), enc_key=ENCRYPTION_KEY)
931 self.update_userdata(secret_2fa=safe_str(encrypted_value))
932
877 def regenerate_2fa_recovery_codes(self):
933 def regenerate_2fa_recovery_codes(self):
878 """
934 """
879 Regenerates 2fa recovery codes upon request
935 Regenerates 2fa recovery codes upon request
880 """
936 """
881 self.update_userdata(recovery_codes_2fa=[])
937 new_recovery_codes = self.init_2fa_recovery_codes(force=True)
882 Session().flush()
883 new_recovery_codes = self.get_2fa_recovery_codes()
884 Session().commit()
938 Session().commit()
885 return new_recovery_codes
939 return new_recovery_codes
886
940
@@ -5021,8 +5075,7 b' class Gist(Base, BaseModel):'
5021 return data
5075 return data
5022
5076
5023 def __json__(self):
5077 def __json__(self):
5024 data = dict(
5078 data = dict()
5025 )
5026 data.update(self.get_api_data())
5079 data.update(self.get_api_data())
5027 return data
5080 return data
5028 # SCM functions
5081 # SCM functions
@@ -111,6 +111,7 b' def TOTPForm(localizer, user, allow_reco'
111 allow_extra_fields = True
111 allow_extra_fields = True
112 filter_extra_fields = False
112 filter_extra_fields = False
113 totp = v.Regex(r'^(?:\d{6}|[A-Z0-9]{32})$')
113 totp = v.Regex(r'^(?:\d{6}|[A-Z0-9]{32})$')
114 secret_totp = v.String()
114
115
115 def to_python(self, value, state=None):
116 def to_python(self, value, state=None):
116 validation_checks = [user.is_totp_valid]
117 validation_checks = [user.is_totp_valid]
@@ -118,10 +119,12 b' def TOTPForm(localizer, user, allow_reco'
118 validation_checks.append(user.is_2fa_recovery_code_valid)
119 validation_checks.append(user.is_2fa_recovery_code_valid)
119 form_data = super().to_python(value, state)
120 form_data = super().to_python(value, state)
120 received_code = form_data['totp']
121 received_code = form_data['totp']
121 if not any(map(lambda x: x(received_code), validation_checks)):
122 secret = form_data.get('secret_totp')
123
124 if not any(map(lambda func: func(received_code, secret), validation_checks)):
122 error_msg = _('Code is invalid. Try again!')
125 error_msg = _('Code is invalid. Try again!')
123 raise formencode.Invalid(error_msg, v, state, error_dict={'totp': error_msg})
126 raise formencode.Invalid(error_msg, v, state, error_dict={'totp': error_msg})
124 return True
127 return form_data
125
128
126 return _TOTPForm
129 return _TOTPForm
127
130
@@ -95,6 +95,7 b' function registerRCRoutes() {'
95 pyroutes.register('channelstream_connect', '/_admin/channelstream/connect', []);
95 pyroutes.register('channelstream_connect', '/_admin/channelstream/connect', []);
96 pyroutes.register('channelstream_proxy', '/_channelstream', []);
96 pyroutes.register('channelstream_proxy', '/_channelstream', []);
97 pyroutes.register('channelstream_subscribe', '/_admin/channelstream/subscribe', []);
97 pyroutes.register('channelstream_subscribe', '/_admin/channelstream/subscribe', []);
98 pyroutes.register('check_2fa', '/_admin/check_2fa', []);
98 pyroutes.register('commit_draft_comments_submit', '/%(repo_name)s/changeset/%(commit_id)s/draft_comments_submit', ['repo_name', 'commit_id']);
99 pyroutes.register('commit_draft_comments_submit', '/%(repo_name)s/changeset/%(commit_id)s/draft_comments_submit', ['repo_name', 'commit_id']);
99 pyroutes.register('debug_style_email', '/_admin/debug_style/email/%(email_id)s', ['email_id']);
100 pyroutes.register('debug_style_email', '/_admin/debug_style/email/%(email_id)s', ['email_id']);
100 pyroutes.register('debug_style_email_plain_rendered', '/_admin/debug_style/email-rendered/%(email_id)s', ['email_id']);
101 pyroutes.register('debug_style_email_plain_rendered', '/_admin/debug_style/email-rendered/%(email_id)s', ['email_id']);
@@ -218,22 +219,23 b' function registerRCRoutes() {'
218 pyroutes.register('my_account_emails', '/_admin/my_account/emails', []);
219 pyroutes.register('my_account_emails', '/_admin/my_account/emails', []);
219 pyroutes.register('my_account_emails_add', '/_admin/my_account/emails/new', []);
220 pyroutes.register('my_account_emails_add', '/_admin/my_account/emails/new', []);
220 pyroutes.register('my_account_emails_delete', '/_admin/my_account/emails/delete', []);
221 pyroutes.register('my_account_emails_delete', '/_admin/my_account/emails/delete', []);
222 pyroutes.register('my_account_enable_2fa', '/_admin/my_account/enable_2fa', []);
223 pyroutes.register('my_account_enable_2fa_save', '/_admin/my_account/enable_2fa_save', []);
221 pyroutes.register('my_account_external_identity', '/_admin/my_account/external-identity', []);
224 pyroutes.register('my_account_external_identity', '/_admin/my_account/external-identity', []);
222 pyroutes.register('my_account_external_identity_delete', '/_admin/my_account/external-identity/delete', []);
225 pyroutes.register('my_account_external_identity_delete', '/_admin/my_account/external-identity/delete', []);
223 pyroutes.register('my_account_goto_bookmark', '/_admin/my_account/bookmark/%(bookmark_id)s', ['bookmark_id']);
226 pyroutes.register('my_account_goto_bookmark', '/_admin/my_account/bookmark/%(bookmark_id)s', ['bookmark_id']);
224 pyroutes.register('my_account_notifications', '/_admin/my_account/notifications', []);
227 pyroutes.register('my_account_notifications', '/_admin/my_account/notifications', []);
225 pyroutes.register('my_account_notifications_test_channelstream', '/_admin/my_account/test_channelstream', []);
228 pyroutes.register('my_account_notifications_test_channelstream', '/_admin/my_account/test_channelstream', []);
226 pyroutes.register('my_account_notifications_toggle_visibility', '/_admin/my_account/toggle_visibility', []);
229 pyroutes.register('my_account_notifications_toggle_visibility', '/_admin/my_account/toggle_visibility', []);
227 pyroutes.register('check_2fa', '/_admin/check_2fa', []);
228 pyroutes.register('my_account_configure_2fa', '/_admin/my_account/configure_2fa', []);
229 pyroutes.register('my_account_regenerate_2fa_recovery_codes', '/_admin/my_account/regenerate_recovery_codes', []);
230 pyroutes.register('my_account_password', '/_admin/my_account/password', []);
230 pyroutes.register('my_account_password', '/_admin/my_account/password', []);
231 pyroutes.register('my_account_password_update', '/_admin/my_account/password/update', []);
231 pyroutes.register('my_account_password_update', '/_admin/my_account/password/update', []);
232 pyroutes.register('my_account_perms', '/_admin/my_account/perms', []);
232 pyroutes.register('my_account_perms', '/_admin/my_account/perms', []);
233 pyroutes.register('my_account_profile', '/_admin/my_account/profile', []);
233 pyroutes.register('my_account_profile', '/_admin/my_account/profile', []);
234 pyroutes.register('my_account_pullrequests', '/_admin/my_account/pull_requests', []);
234 pyroutes.register('my_account_pullrequests', '/_admin/my_account/pull_requests', []);
235 pyroutes.register('my_account_pullrequests_data', '/_admin/my_account/pull_requests/data', []);
235 pyroutes.register('my_account_pullrequests_data', '/_admin/my_account/pull_requests/data', []);
236 pyroutes.register('my_account_regenerate_2fa_recovery_codes', '/_admin/my_account/regenerate_recovery_codes', []);
236 pyroutes.register('my_account_repos', '/_admin/my_account/repos', []);
237 pyroutes.register('my_account_repos', '/_admin/my_account/repos', []);
238 pyroutes.register('my_account_show_2fa_recovery_codes', '/_admin/my_account/recovery_codes', []);
237 pyroutes.register('my_account_ssh_keys', '/_admin/my_account/ssh_keys', []);
239 pyroutes.register('my_account_ssh_keys', '/_admin/my_account/ssh_keys', []);
238 pyroutes.register('my_account_ssh_keys_add', '/_admin/my_account/ssh_keys/new', []);
240 pyroutes.register('my_account_ssh_keys_add', '/_admin/my_account/ssh_keys/new', []);
239 pyroutes.register('my_account_ssh_keys_delete', '/_admin/my_account/ssh_keys/delete', []);
241 pyroutes.register('my_account_ssh_keys_delete', '/_admin/my_account/ssh_keys/delete', []);
@@ -382,6 +384,7 b' function registerRCRoutes() {'
382 pyroutes.register('search_repo', '/%(repo_name)s/_search', ['repo_name']);
384 pyroutes.register('search_repo', '/%(repo_name)s/_search', ['repo_name']);
383 pyroutes.register('search_repo_alt', '/%(repo_name)s/search', ['repo_name']);
385 pyroutes.register('search_repo_alt', '/%(repo_name)s/search', ['repo_name']);
384 pyroutes.register('search_repo_group', '/%(repo_group_name)s/_search', ['repo_group_name']);
386 pyroutes.register('search_repo_group', '/%(repo_group_name)s/_search', ['repo_group_name']);
387 pyroutes.register('setup_2fa', '/_admin/setup_2fa', []);
385 pyroutes.register('store_user_session_value', '/_store_session_attr', []);
388 pyroutes.register('store_user_session_value', '/_store_session_attr', []);
386 pyroutes.register('strip_check', '/%(repo_name)s/settings/strip_check', ['repo_name']);
389 pyroutes.register('strip_check', '/%(repo_name)s/settings/strip_check', ['repo_name']);
387 pyroutes.register('strip_execute', '/%(repo_name)s/settings/strip_execute', ['repo_name']);
390 pyroutes.register('strip_execute', '/%(repo_name)s/settings/strip_execute', ['repo_name']);
@@ -4,6 +4,7 b''
4 <div class="panel-heading">
4 <div class="panel-heading">
5 <h3 class="panel-title">${_('Enable/Disable 2FA for your account')}</h3>
5 <h3 class="panel-title">${_('Enable/Disable 2FA for your account')}</h3>
6 </div>
6 </div>
7 ${h.secure_form(h.route_path('my_account_enable_2fa_save'), request=request)}
7 <div class="panel-body">
8 <div class="panel-body">
8 <div class="form">
9 <div class="form">
9 <div class="fields">
10 <div class="fields">
@@ -12,112 +13,122 b''
12 <label>${_('2FA status')}:</label>
13 <label>${_('2FA status')}:</label>
13 </div>
14 </div>
14 <div class="checkboxes">
15 <div class="checkboxes">
15
16 <div class="form-check">
17 <label class="form-check-label">
18 <input type="radio" id="2faEnabled" value="1" ${'checked' if c.state_of_2fa else ''}>
19 ${_('Enabled')}
20 </label>
21 <label class="form-check-label">
22 <input type="radio" id="2faDisabled" value="0" ${'checked' if not c.state_of_2fa else ''}>
23 ${_('Disabled')}
24 </label>
25 </div>
26 % if c.locked_2fa:
16 % if c.locked_2fa:
27 <span class="help-block">${_('2FA settings cannot be changed here, because 2FA was forced enabled by RhodeCode Administrator.')}</span>
17 <span class="help-block">${_('2FA settings cannot be changed here, because 2FA was forced enabled by RhodeCode Administrator.')}</span>
18
19 % else:
20 <div class="form-check">
21 <input type="radio" id="2faEnabled" name="2fa_status" value="1" ${'checked=1' if c.state_of_2fa else ''}/>
22 <label for="2faEnabled">${_('Enable 2FA')}</label>
23
24 <input type="radio" id="2faDisabled" name="2fa_status" value="0" ${'checked=1' if not c.state_of_2fa else ''} />
25 <label for="2faDisabled">${_('Disable 2FA')}</label>
26 </div>
28 % endif
27 % endif
28
29 </div>
29 </div>
30 </div>
30 </div>
31 </div>
31 </div>
32 <button id="saveBtn" class="btn btn-primary" ${'disabled' if c.locked_2fa else ''}>${_('Save')}</button>
32 <button id="saveBtn" class="btn btn-primary" ${'disabled' if c.locked_2fa else ''}>${_('Save')}</button>
33 </div>
33 </div>
34 </div>
34 </div>
35 ${h.end_form()}
35 </div>
36 </div>
36
37
37 % if c.state_of_2fa:
38 % if c.state_of_2fa:
39
40
41 % if not c.user_seen_2fa_recovery_codes:
42
43 <div class="panel panel-warning">
44 <div class="panel-heading" id="advanced-archive">
45 <h3 class="panel-title">${_('2FA Recovery codes')} <a class="permalink" href="#advanced-archive"> ΒΆ</a></h3>
46 </div>
47 <div class="panel-body">
48 <p>
49 ${_('You have not seen your 2FA recovery codes yet.')}
50 ${_('Please save them in a safe place, or you will lose access to your account in case of lost access to authenticator app.')}
51 </p>
52 <br/>
53 <a href="${request.route_path('my_account_enable_2fa', _query={'show-recovery-codes': 1})}" class="btn btn-primary">${_('Show recovery codes')}</a>
54 </div>
55 </div>
56 % endif
57
58
59 ${h.secure_form(h.route_path('my_account_regenerate_2fa_recovery_codes'), request=request)}
38 <div class="panel panel-default">
60 <div class="panel panel-default">
39 <div class="panel-heading">
61 <div class="panel-heading">
40 <h3 class="panel-title">${_('Regenerate 2FA recovery codes for your account')}</h3>
62 <h3 class="panel-title">${_('Regenerate 2FA recovery codes for your account')}</h3>
41 </div>
63 </div>
42 <div class="panel-body">
64 <div class="panel-body">
43 <form id="2faForm">
65 <form id="2faForm">
44 <input type="text" name="totp" placeholder="${_('Verify the code from the app')}" pattern="\d{6}"
66 <input type="text" name="totp" placeholder="${_('Verify the code from the app')}" pattern="\d{6}" style="width: 20%">
45 style="width: 20%">
67 <button type="submit" class="btn btn-primary">${_('Verify and generate new codes')}</button>
46 <button type="button" class="btn btn-primary" onclick="submitForm()">Verify</button>
47 </form>
68 </form>
48 <div id="result"></div>
49 </div>
69 </div>
50
70
51 </div>
71 </div>
72 ${h.end_form()}
73 % endif
52
74
53 % endif
54
75
55 <script>
76 <script>
56 function submitForm() {
77
57 let formData = new FormData(document.getElementById("2faForm"));
78 function showRecoveryCodesPopup() {
58 let xhr = new XMLHttpRequest();
59
79
60 let success = function (response) {
80 SwalNoAnimation.fire({
61 let recovery_codes = response.recovery_codes;
81 title: _gettext('2FA recovery codes'),
62 showRecoveryCodesPopup(recovery_codes);
82 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>',
63 }
83 showCancelButton: false,
84 showConfirmButton: true,
85 showLoaderOnConfirm: true,
86 confirmButtonText: _gettext('Show now'),
87 allowOutsideClick: function () {
88 !Swal.isLoading()
89 },
90
91 preConfirm: function () {
64
92
65 xhr.onreadystatechange = function () {
93 var postData = {
66 if (xhr.readyState == 4 && xhr.status == 200) {
94 'csrf_token': CSRF_TOKEN
67 let responseDoc = new DOMParser().parseFromString(xhr.responseText, "text/html");
68 let contentToDisplay = responseDoc.querySelector('#formErrors');
69 if (contentToDisplay) {
70 document.getElementById("result").innerHTML = contentToDisplay.innerHTML;
71 } else {
72 let regenerate_url = pyroutes.url('my_account_regenerate_2fa_recovery_codes');
73 ajaxPOST(regenerate_url, {'csrf_token': CSRF_TOKEN}, success);
74 }
75 }
76 };
95 };
77 let url = pyroutes.url('check_2fa');
96 return new Promise(function (resolve, reject) {
78 xhr.open("POST", url, true);
97 $.ajax({
79 xhr.send(formData);
98 type: 'POST',
99 data: postData,
100 url: pyroutes.url('my_account_show_2fa_recovery_codes'),
101 headers: {'X-PARTIAL-XHR': true}
102 })
103 .done(function (data) {
104 resolve(data);
105 })
106 .fail(function (jqXHR, textStatus, errorThrown) {
107 var message = formatErrorMessage(jqXHR, textStatus, errorThrown);
108 ajaxErrorSwal(message);
109 });
110 })
80 }
111 }
81
112
82 document.getElementById('2faEnabled').addEventListener('click', function () {
113 })
83 document.getElementById('2faDisabled').checked = false;
114 .then(function (result) {
84 });
115 if (result.value) {
85 document.getElementById('2faDisabled').addEventListener('click', function () {
116 let funcData = {'recoveryCodes': result.value.recovery_codes}
86 document.getElementById('2faEnabled').checked = false;
117 let recoveryCodesHtml = renderTemplate('recoveryCodes', funcData);
87 });
88
89 function getStateValue() {
90 if (document.getElementById('2faEnabled').checked) {
91 return '1';
92 } else {
93 return '0';
94 }
95 };
96
97 function saveChanges(state) {
98
99 let post_data = {'state': state, 'csrf_token': CSRF_TOKEN};
100 let url = pyroutes.url('my_account_configure_2fa');
101
102 ajaxPOST(url, post_data, function(){}, function(){})
103 }
104
105 document.getElementById('saveBtn').addEventListener('click', function () {
106 var state = getStateValue();
107 saveChanges(state);
108 });
109
110 function showRecoveryCodesPopup(recoveryCodes) {
111 let funcData = {'recoveryCodes': recoveryCodes}
112 let recoveryCodesHtml = renderTemplate('recoveryCodes', funcData)
113
114 SwalNoAnimation.fire({
118 SwalNoAnimation.fire({
115 allowOutsideClick: false,
119 allowOutsideClick: false,
116 confirmButtonText: _gettext('I Copied the codes'),
120 confirmButtonText: _gettext('I Copied the codes'),
117 title: _gettext('2FA Recovery Codes'),
121 title: _gettext('2FA Recovery Codes'),
118 html: recoveryCodesHtml
122 html: recoveryCodesHtml
123 }).then(function (result) {
124 if (result.isConfirmed) {
125 window.location.reload()
126 }
119 })
127 })
120
121 }
128 }
122
129 })
130 }
131 % if request.GET.get('show-recovery-codes') == '1' and not c.user_seen_2fa_recovery_codes:
132 showRecoveryCodesPopup();
133 % endif
123 </script>
134 </script>
@@ -1,7 +1,7 b''
1 <%inherit file="base/root.mako"/>
1 <%inherit file="base/root.mako"/>
2
2
3 <%def name="title()">
3 <%def name="title()">
4 ${_('Setup authenticator app')}
4 ${_('Setup 2FA')}
5 %if c.rhodecode_name:
5 %if c.rhodecode_name:
6 &middot; ${h.branding(c.rhodecode_name)}
6 &middot; ${h.branding(c.rhodecode_name)}
7 %endif
7 %endif
@@ -22,31 +22,28 b''
22 </div>
22 </div>
23
23
24 <div class="loginwrapper">
24 <div class="loginwrapper">
25 <h1>Setup the authenticator app</h1>
25 <h1>${_('Setup the authenticator app')}</h1>
26
26 <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>
27 <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>
27 <rhodecode-toast id="notifications"></rhodecode-toast>
28 <rhodecode-toast id="notifications"></rhodecode-toast>
28
29
29 <div id="setup_2fa">
30 <div id="setup_2fa">
31 ${h.secure_form(h.route_path('setup_2fa'), request=request, id='totp_form')}
30 <div class="sign-in-title">
32 <div class="sign-in-title">
31 <h1>${_('Scan the QR code')}</h1>
33 <h1>${_('Scan the QR code')}: "${totp_name}"</h1>
32 </div>
34 </div>
33 <p>Use an authenticator app to scan.</p>
35 <p>${_('Use an authenticator app to scan.')}</p>
34 <img src="data:image/png;base64, ${qr}"/>
36 <img alt="qr-code" src="data:image/png;base64, ${qr}"/>
37
35 <p>${_('Unable to scan?')} <a id="toggleLink">${_('Click here')}</a></p>
38 <p>${_('Unable to scan?')} <a id="toggleLink">${_('Click here')}</a></p>
36 <div id="secretDiv" class="hidden">
39 <div id="secretDiv" class="hidden">
37 <p>${_('Copy and use this code to manually setup an authenticator app')}</p>
40 <p>${_('Copy and use this code to manually set up an authenticator app')}</p>
38 <input type="text" id="secretField" value=${key}>
41 <input type="text" class="input-monospace" value="${key}" id="secret_totp" name="secret_totp" style="width: 400px"/>
39 <i class="tooltip icon-clipboard clipboard-action" data-clipboard-text="" title="${_('Copy the secret key')}"></i>
42 <i class="tooltip icon-clipboard clipboard-action" data-clipboard-text="${key}" title="${_('Copy the secret key')}"></i>
40 </div>
43 </div>
41 <div id="codesPopup" class="modal">
44
42 <div class="modal-content">
43 <ul id="recoveryCodesList"></ul>
44 <button id="copyAllBtn" class="btn btn-primary">Copy All</button>
45 </div>
46 </div>
47 <br><br>
48 <div id="verify_2fa">
45 <div id="verify_2fa">
49 ${h.secure_form(h.route_path('setup_2fa'), request=request, id='totp_form')}
46
50 <div class="form mt-4">
47 <div class="form mt-4">
51 <div class="field">
48 <div class="field">
52 <p>
49 <p>
@@ -65,7 +62,7 b''
65 %endif
62 % endif
66 </div>
63 </div>
67 <div class="input-group-append">
64 <div class="input-group-append">
68 ${h.submit('save',_('Verify'),class_="btn btn-primary", style='width: 40%;', disabled=not codes_viewed)}
65 ${h.submit('verify_2fa',_('Verify'),class_="btn btn-primary", style='width: 40%;')}
69 </div>
66 </div>
70 </div>
67 </div>
71 </div>
68 </div>
@@ -73,81 +70,18 b''
73 </div>
70 </div>
74 </div>
71 </div>
75 </div>
72 </div>
73 ${h.end_form()}
76 </div>
74 </div>
77 </div>
75 </div>
78 </div>
76 </div>
79 <script>
80 document.addEventListener('DOMContentLoaded', function() {
81 let clipboardIcons = document.querySelectorAll('.clipboard-action');
82
77
83 clipboardIcons.forEach(function(icon) {
78 <script>
84 icon.addEventListener('click', function() {
85 var inputField = document.getElementById('secretField');
86 inputField.select();
87 document.execCommand('copy');
88
79
89 });
90 });
91 });
92 </script>
93 <script>
94 document.getElementById('toggleLink').addEventListener('click', function() {
80 document.getElementById('toggleLink').addEventListener('click', function() {
95 let hiddenField = document.getElementById('secretDiv');
81 let hiddenField = document.getElementById('secretDiv');
96 if (hiddenField.classList.contains('hidden')) {
82 if (hiddenField.classList.contains('hidden')) {
97 hiddenField.classList.remove('hidden');
83 hiddenField.classList.remove('hidden');
98 }
84 }
99 });
85 });
86
100 </script>
87 </script>
101 <script>
102 const recovery_codes_string = '${recovery_codes}';
103 const cleaned_recovery_codes_string = recovery_codes_string
104 .replace(/&#34;/g, '"')
105 .replace(/&#39;/g, "'");
106
107 const recovery_codes = JSON.parse(cleaned_recovery_codes_string);
108
109 const cleaned_recovery_codes = recovery_codes.map(code => code.replace(/['"]/g, ''));
110
111 function showRecoveryCodesPopup() {
112 const popup = document.getElementById("codesPopup");
113 const codesList = document.getElementById("recoveryCodesList");
114 const verify_btn = document.getElementById('save')
115
116 if (verify_btn.disabled) {
117 codesList.innerHTML = "";
118
119 cleaned_recovery_codes.forEach(code => {
120 const listItem = document.createElement("li");
121 listItem.textContent = code;
122 codesList.appendChild(listItem);
123 });
124
125 popup.style.display = "block";
126 verify_btn.disabled = false;
127 }
128 }
129
130 document.getElementById("save").addEventListener("mouseover", showRecoveryCodesPopup);
131
132 const popup = document.getElementById("codesPopup");
133 const closeButton = document.querySelector(".close");
134 window.onclick = function(event) {
135 if (event.target === popup || event.target === closeButton) {
136 popup.style.display = "none";
137 }
138 }
139
140 document.getElementById("copyAllBtn").addEventListener("click", function() {
141 const codesListItems = document.querySelectorAll("#recoveryCodesList li");
142 const allCodes = Array.from(codesListItems).map(item => item.textContent).join(", ");
143
144 const textarea = document.createElement('textarea');
145 textarea.value = allCodes;
146 document.body.appendChild(textarea);
147
148 textarea.select();
149 document.execCommand('copy');
150
151 document.body.removeChild(textarea);
152 });
153 </script>
@@ -1,37 +1,54 b''
1 <%inherit file="/base/root.mako"/>
1 <%inherit file="base/root.mako"/>
2
2 <%def name="title()">
3 <%def name="title()">
3 ${_('Check 2FA')}
4 ${_('Verify 2FA')}
4 %if c.rhodecode_name:
5 %if c.rhodecode_name:
5 &middot; ${h.branding(c.rhodecode_name)}
6 &middot; ${h.branding(c.rhodecode_name)}
6 %endif
7 %endif
7 </%def>
8 </%def>
9 <style>body{background-color:#eeeeee;}</style>
8
10
9 <div class="box">
11 <div class="loginbox">
10 <div class="verify2FA">
12 <div class="header-account">
13 <div id="header-inner" class="title">
14 <div id="logo">
15 % if c.rhodecode_name:
16 <div class="branding">
17 <a href="${h.route_path('home')}">${h.branding(c.rhodecode_name)}</a>
18 </div>
19 % endif
20 </div>
21 </div>
22 </div>
23
24 <div class="loginwrapper">
25 <rhodecode-toast id="notifications"></rhodecode-toast>
26
27 <div id="register">
28 <div class="sign-in-title">
29 <h1>${_('Verify the code from the app')}</h1>
30 </div>
31 <div class="inner form">
11 ${h.secure_form(h.route_path('check_2fa'), request=request, id='totp_form')}
32 ${h.secure_form(h.route_path('check_2fa'), request=request, id='totp_form')}
12 <div class="form mt-4" style="position: relative; margin-left: 35%; margin-top: 20%;">
33 <label for="totp">${_('Verification code')}:</label>
13 <div class="field">
34 ${h.text('totp', class_="form-control")}
14 <p>
15 <div class="label">
16 <label for="totp" class="form-label text-dark font-weight-bold" style="text-align: left;">${_('Verify the code from the app')}:</label>
17 </div>
18 </p>
19 <p>
20 <div>
21 <div class="input-group">
22 ${h.text('totp', class_="form-control", style='width: 38%;')}
23 <div id="formErrors">
24 %if 'totp' in errors:
35 %if 'totp' in errors:
25 <span class="error-message">${errors.get('totp')}</span>
36 <span class="error-message">${errors.get('totp')}</span>
26 <br />
37 <br />
27 %endif
38 %endif
28 </div>
39 <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>
29 <br />
40
30 ${h.submit('save',_('Verify'),class_="btn btn-primary", style='width: 40%;')}
41 ${h.submit('send', _('Verify'), class_="btn sign-in")}
42 <p class="help-block pull-right">
43 RhodeCode ${c.rhodecode_edition}
44 </p>
45 ${h.end_form()}
31 </div>
46 </div>
32 </div>
47 </div>
33 </p>
48
34 </div>
49 </div>
35 </div>
50 </div>
36 </div>
51
37 </div>
52
53
54
General Comments 0
You need to be logged in to leave comments. Login now