Show More
@@ -178,9 +178,7 b' class BaseAppView(object):' | |||
|
178 | 178 | if user_obj.has_forced_2fa and user_obj.extern_type != 'rhodecode': |
|
179 | 179 | return |
|
180 | 180 | |
|
181 | if (user_obj.has_enabled_2fa | |
|
182 | and not self.user_data.get('secret_2fa')) \ | |
|
183 | and view_name != self.SETUP_2FA_VIEW: | |
|
181 | if user_obj.needs_2fa_configure and view_name != self.SETUP_2FA_VIEW: | |
|
184 | 182 | h.flash( |
|
185 | 183 | "You are required to configure 2FA", |
|
186 | 184 | "warning", |
@@ -195,7 +193,7 b' class BaseAppView(object):' | |||
|
195 | 193 | if not user_obj: |
|
196 | 194 | return |
|
197 | 195 | |
|
198 |
if |
|
|
196 | if user_obj.has_check_2fa_flag and view_name != self.VERIFY_2FA_VIEW: | |
|
199 | 197 | raise HTTPFound(self.request.route_path(self.VERIFY_2FA_VIEW)) |
|
200 | 198 | |
|
201 | 199 | def _log_creation_exception(self, e, repo_name): |
@@ -33,7 +33,7 b' class Test2FA(object):' | |||
|
33 | 33 | def test_redirect_to_2fa_check_if_2fa_configured(self, user_util): |
|
34 | 34 | user = user_util.create_user(password=self.password) |
|
35 | 35 | user.has_enabled_2fa = True |
|
36 | user.secret_2fa | |
|
36 | user.init_secret_2fa() | |
|
37 | 37 | Session().add(user) |
|
38 | 38 | Session().commit() |
|
39 | 39 | self.app.post( |
@@ -47,8 +47,8 b' class Test2FA(object):' | |||
|
47 | 47 | def test_2fa_recovery_codes_works_only_once(self, user_util): |
|
48 | 48 | user = user_util.create_user(password=self.password) |
|
49 | 49 | user.has_enabled_2fa = True |
|
50 | user.secret_2fa | |
|
51 |
recovery_cod_to_check = user. |
|
|
50 | user.init_secret_2fa() | |
|
51 | recovery_cod_to_check = user.init_2fa_recovery_codes()[0] | |
|
52 | 52 | Session().add(user) |
|
53 | 53 | Session().commit() |
|
54 | 54 | self.app.post( |
@@ -188,7 +188,8 b' class LoginView(BaseAppView):' | |||
|
188 | 188 | # form checks for username/password, now we're authenticated |
|
189 | 189 | username = form_result['username'] |
|
190 | 190 | if (user := User.get_by_username_or_primary_email(username)).has_enabled_2fa: |
|
191 |
user. |
|
|
191 | user.has_check_2fa_flag = True | |
|
192 | ||
|
192 | 193 | headers = store_user_in_session( |
|
193 | 194 | self.session, |
|
194 | 195 | user_identifier=username, |
@@ -489,23 +490,32 b' class LoginView(BaseAppView):' | |||
|
489 | 490 | form = TOTPForm(_, user_instance)() |
|
490 | 491 | render_ctx = {} |
|
491 | 492 | if self.request.method == 'POST': |
|
493 | post_items = dict(self.request.POST) | |
|
494 | ||
|
492 | 495 | try: |
|
493 |
form.to_python( |
|
|
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 | 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 | 504 | except formencode.Invalid as errors: |
|
497 | 505 | defaults = errors.value |
|
498 | 506 | render_ctx = { |
|
499 | 507 | 'errors': errors.error_dict, |
|
500 | 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 | 517 | qr = qrcode.QRCode(version=1, box_size=10, border=5) |
|
503 | secret = user_instance.secret_2fa | |
|
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)) | |
|
518 | qr.add_data(pyotp.totp.TOTP(secret).provisioning_uri(name=totp_name)) | |
|
509 | 519 | qr.make(fit=True) |
|
510 | 520 | img = qr.make_image(fill_color='black', back_color='white') |
|
511 | 521 | buffered = BytesIO() |
@@ -513,8 +523,8 b' class LoginView(BaseAppView):' | |||
|
513 | 523 | return self._get_template_context( |
|
514 | 524 | c, |
|
515 | 525 | qr=b64encode(buffered.getvalue()).decode("utf-8"), |
|
516 | key=secret, recovery_codes=json.dumps(recovery_codes), | |
|
517 | codes_viewed=not bool(recovery_codes), | |
|
526 | key=secret, | |
|
527 | totp_name=totp_name, | |
|
518 | 528 | ** render_ctx |
|
519 | 529 | ) |
|
520 | 530 | |
@@ -527,9 +537,12 b' class LoginView(BaseAppView):' | |||
|
527 | 537 | user_instance = self._rhodecode_db_user |
|
528 | 538 | totp_form = TOTPForm(_, user_instance, allow_recovery_code_use=True)() |
|
529 | 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 | 543 | try: |
|
531 |
totp_form.to_python( |
|
|
532 |
user_instance. |
|
|
544 | totp_form.to_python(post_items) | |
|
545 | user_instance.has_check_2fa_flag = False | |
|
533 | 546 | Session().commit() |
|
534 | 547 | raise HTTPFound(c.came_from) |
|
535 | 548 | except formencode.Invalid as errors: |
@@ -83,24 +83,35 b' def includeme(config):' | |||
|
83 | 83 | attr='my_account_2fa', |
|
84 | 84 | route_name='my_account_enable_2fa', request_method='GET', |
|
85 | 85 | renderer='rhodecode:templates/admin/my_account/my_account.mako') |
|
86 | ||
|
86 | # my account 2fa save | |
|
87 | 87 | config.add_route( |
|
88 |
name='my_account_ |
|
|
89 |
pattern=ADMIN_PREFIX + '/my_account/ |
|
|
88 | name='my_account_enable_2fa_save', | |
|
89 | pattern=ADMIN_PREFIX + '/my_account/enable_2fa_save') | |
|
90 | 90 | config.add_view( |
|
91 | 91 | MyAccountView, |
|
92 |
attr='my_account_2fa_ |
|
|
93 |
route_name='my_account_ |
|
|
92 | attr='my_account_2fa_update', | |
|
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 | 104 | renderer='json_ext') |
|
95 | 105 | |
|
106 | # my account 2fa recovery code-reset | |
|
96 | 107 | config.add_route( |
|
97 | 108 | name='my_account_regenerate_2fa_recovery_codes', |
|
98 | 109 | pattern=ADMIN_PREFIX + '/my_account/regenerate_recovery_codes') |
|
99 | 110 | config.add_view( |
|
100 | 111 | MyAccountView, |
|
101 | 112 | attr='my_account_2fa_regenerate_recovery_codes', |
|
102 |
route_name='my_account_regenerate_2fa_recovery_codes', request_method='POST', |
|
|
103 | renderer='json_ext') | |
|
113 | route_name='my_account_regenerate_2fa_recovery_codes', request_method='POST', | |
|
114 | renderer='rhodecode:templates/admin/my_account/my_account.mako') | |
|
104 | 115 | |
|
105 | 116 | # my account tokens |
|
106 | 117 | config.add_route( |
@@ -16,6 +16,7 b'' | |||
|
16 | 16 | # RhodeCode Enterprise Edition, including its added features, Support services, |
|
17 | 17 | # and proprietary license terms, please see https://rhodecode.com/licenses/ |
|
18 | 18 | |
|
19 | import time | |
|
19 | 20 | import logging |
|
20 | 21 | import datetime |
|
21 | 22 | import string |
@@ -43,6 +44,7 b' from rhodecode.model.db import (' | |||
|
43 | 44 | IntegrityError, or_, in_filter_generator, select, |
|
44 | 45 | Repository, UserEmailMap, UserApiKeys, UserFollowing, |
|
45 | 46 | PullRequest, UserBookmark, RepoGroup, ChangesetStatus) |
|
47 | from rhodecode.model.forms import TOTPForm | |
|
46 | 48 | from rhodecode.model.meta import Session |
|
47 | 49 | from rhodecode.model.pull_request import PullRequestModel |
|
48 | 50 | from rhodecode.model.user import UserModel |
@@ -207,27 +209,65 b' class MyAccountView(BaseAppView, DataGri' | |||
|
207 | 209 | def my_account_2fa(self): |
|
208 | 210 | _ = self.request.translate |
|
209 | 211 | c = self.load_default_context() |
|
210 |
c.active = '2 |
|
|
211 | from rhodecode.model.settings import SettingsModel | |
|
212 | user_instance = self._rhodecode_db_user | |
|
212 | c.active = '2FA' | |
|
213 | user_instance = c.auth_user.get_instance() | |
|
213 | 214 | locked_by_admin = user_instance.has_forced_2fa |
|
214 | 215 | c.state_of_2fa = user_instance.has_enabled_2fa |
|
216 | c.user_seen_2fa_recovery_codes = user_instance.has_seen_2fa_codes | |
|
215 | 217 | c.locked_2fa = str2bool(locked_by_admin) |
|
216 | 218 | return self._get_template_context(c) |
|
217 | 219 | |
|
218 | 220 | @LoginRequired() |
|
219 | 221 | @NotAnonymous() |
|
220 | 222 | @CSRFRequired() |
|
221 |
def my_account_2fa_ |
|
|
222 |
|
|
|
223 | self._rhodecode_db_user.has_enabled_2fa = state | |
|
224 | return {'state_of_2fa': state} | |
|
223 | def my_account_2fa_update(self): | |
|
224 | _ = self.request.translate | |
|
225 | c = self.load_default_context() | |
|
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 | 246 | @LoginRequired() |
|
227 | 247 | @NotAnonymous() |
|
228 | 248 | @CSRFRequired() |
|
229 | 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 | 272 | @LoginRequired() |
|
233 | 273 | @NotAnonymous() |
@@ -796,34 +796,13 b' class User(Base, BaseModel):' | |||
|
796 | 796 | Session.commit() |
|
797 | 797 | return artifact_token.api_key |
|
798 | 798 | |
|
799 | @hybrid_property | |
|
800 | def secret_2fa(self): | |
|
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) | |
|
799 | def is_totp_valid(self, received_code, secret): | |
|
800 | totp = pyotp.TOTP(secret) | |
|
815 | 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 | 804 | encrypted_recovery_codes = self.user_data.get('recovery_codes_2fa', []) |
|
819 |
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)) | |
|
805 | recovery_codes = self.get_2fa_recovery_codes() | |
|
827 | 806 | if received_code in recovery_codes: |
|
828 | 807 | encrypted_recovery_codes.pop(recovery_codes.index(received_code)) |
|
829 | 808 | self.update_userdata(recovery_codes_2fa=encrypted_recovery_codes) |
@@ -844,7 +823,7 b' class User(Base, BaseModel):' | |||
|
844 | 823 | @hybrid_property |
|
845 | 824 | def has_enabled_2fa(self): |
|
846 | 825 | """ |
|
847 |
Checks if |
|
|
826 | Checks if user enabled 2fa | |
|
848 | 827 | """ |
|
849 | 828 | if value := self.has_forced_2fa: |
|
850 | 829 | return value |
@@ -853,34 +832,109 b' class User(Base, BaseModel):' | |||
|
853 | 832 | @has_enabled_2fa.setter |
|
854 | 833 | def has_enabled_2fa(self, val): |
|
855 | 834 | val = str2bool(val) |
|
856 |
self.update_userdata(enabled_2fa= |
|
|
835 | self.update_userdata(enabled_2fa=val) | |
|
857 | 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 | 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 | 882 | Creates 2fa recovery codes |
|
864 | 883 | """ |
|
865 | 884 | recovery_codes = self.user_data.get('recovery_codes_2fa', []) |
|
866 | 885 | encrypted_codes = [] |
|
867 | if not recovery_codes: | |
|
886 | if not recovery_codes or force: | |
|
868 | 887 | for _ in range(self.RECOVERY_CODES_COUNT): |
|
869 | 888 | recovery_code = pyotp.random_base32() |
|
870 | 889 | recovery_codes.append(recovery_code) |
|
871 |
encrypted_code |
|
|
872 | self.update_userdata(recovery_codes_2fa=encrypted_codes) | |
|
890 | encrypted_code = enc_utils.encrypt_value(safe_bytes(recovery_code), enc_key=ENCRYPTION_KEY) | |
|
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 | 894 | return recovery_codes |
|
874 | 895 | # User should not check the same recovery codes more than once |
|
875 | 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 | 933 | def regenerate_2fa_recovery_codes(self): |
|
878 | 934 | """ |
|
879 | 935 | Regenerates 2fa recovery codes upon request |
|
880 | 936 | """ |
|
881 | self.update_userdata(recovery_codes_2fa=[]) | |
|
882 | Session().flush() | |
|
883 | new_recovery_codes = self.get_2fa_recovery_codes() | |
|
937 | new_recovery_codes = self.init_2fa_recovery_codes(force=True) | |
|
884 | 938 | Session().commit() |
|
885 | 939 | return new_recovery_codes |
|
886 | 940 | |
@@ -5021,8 +5075,7 b' class Gist(Base, BaseModel):' | |||
|
5021 | 5075 | return data |
|
5022 | 5076 | |
|
5023 | 5077 | def __json__(self): |
|
5024 | data = dict( | |
|
5025 | ) | |
|
5078 | data = dict() | |
|
5026 | 5079 | data.update(self.get_api_data()) |
|
5027 | 5080 | return data |
|
5028 | 5081 | # SCM functions |
@@ -111,6 +111,7 b' def TOTPForm(localizer, user, allow_reco' | |||
|
111 | 111 | allow_extra_fields = True |
|
112 | 112 | filter_extra_fields = False |
|
113 | 113 | totp = v.Regex(r'^(?:\d{6}|[A-Z0-9]{32})$') |
|
114 | secret_totp = v.String() | |
|
114 | 115 | |
|
115 | 116 | def to_python(self, value, state=None): |
|
116 | 117 | validation_checks = [user.is_totp_valid] |
@@ -118,10 +119,12 b' def TOTPForm(localizer, user, allow_reco' | |||
|
118 | 119 | validation_checks.append(user.is_2fa_recovery_code_valid) |
|
119 | 120 | form_data = super().to_python(value, state) |
|
120 | 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 | 125 | error_msg = _('Code is invalid. Try again!') |
|
123 | 126 | raise formencode.Invalid(error_msg, v, state, error_dict={'totp': error_msg}) |
|
124 |
return |
|
|
127 | return form_data | |
|
125 | 128 | |
|
126 | 129 | return _TOTPForm |
|
127 | 130 |
@@ -95,6 +95,7 b' function registerRCRoutes() {' | |||
|
95 | 95 | pyroutes.register('channelstream_connect', '/_admin/channelstream/connect', []); |
|
96 | 96 | pyroutes.register('channelstream_proxy', '/_channelstream', []); |
|
97 | 97 | pyroutes.register('channelstream_subscribe', '/_admin/channelstream/subscribe', []); |
|
98 | pyroutes.register('check_2fa', '/_admin/check_2fa', []); | |
|
98 | 99 | pyroutes.register('commit_draft_comments_submit', '/%(repo_name)s/changeset/%(commit_id)s/draft_comments_submit', ['repo_name', 'commit_id']); |
|
99 | 100 | pyroutes.register('debug_style_email', '/_admin/debug_style/email/%(email_id)s', ['email_id']); |
|
100 | 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 | 219 | pyroutes.register('my_account_emails', '/_admin/my_account/emails', []); |
|
219 | 220 | pyroutes.register('my_account_emails_add', '/_admin/my_account/emails/new', []); |
|
220 | 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 | 224 | pyroutes.register('my_account_external_identity', '/_admin/my_account/external-identity', []); |
|
222 | 225 | pyroutes.register('my_account_external_identity_delete', '/_admin/my_account/external-identity/delete', []); |
|
223 | 226 | pyroutes.register('my_account_goto_bookmark', '/_admin/my_account/bookmark/%(bookmark_id)s', ['bookmark_id']); |
|
224 | 227 | pyroutes.register('my_account_notifications', '/_admin/my_account/notifications', []); |
|
225 | 228 | pyroutes.register('my_account_notifications_test_channelstream', '/_admin/my_account/test_channelstream', []); |
|
226 | 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 | 230 | pyroutes.register('my_account_password', '/_admin/my_account/password', []); |
|
231 | 231 | pyroutes.register('my_account_password_update', '/_admin/my_account/password/update', []); |
|
232 | 232 | pyroutes.register('my_account_perms', '/_admin/my_account/perms', []); |
|
233 | 233 | pyroutes.register('my_account_profile', '/_admin/my_account/profile', []); |
|
234 | 234 | pyroutes.register('my_account_pullrequests', '/_admin/my_account/pull_requests', []); |
|
235 | 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 | 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 | 239 | pyroutes.register('my_account_ssh_keys', '/_admin/my_account/ssh_keys', []); |
|
238 | 240 | pyroutes.register('my_account_ssh_keys_add', '/_admin/my_account/ssh_keys/new', []); |
|
239 | 241 | pyroutes.register('my_account_ssh_keys_delete', '/_admin/my_account/ssh_keys/delete', []); |
@@ -382,6 +384,7 b' function registerRCRoutes() {' | |||
|
382 | 384 | pyroutes.register('search_repo', '/%(repo_name)s/_search', ['repo_name']); |
|
383 | 385 | pyroutes.register('search_repo_alt', '/%(repo_name)s/search', ['repo_name']); |
|
384 | 386 | pyroutes.register('search_repo_group', '/%(repo_group_name)s/_search', ['repo_group_name']); |
|
387 | pyroutes.register('setup_2fa', '/_admin/setup_2fa', []); | |
|
385 | 388 | pyroutes.register('store_user_session_value', '/_store_session_attr', []); |
|
386 | 389 | pyroutes.register('strip_check', '/%(repo_name)s/settings/strip_check', ['repo_name']); |
|
387 | 390 | pyroutes.register('strip_execute', '/%(repo_name)s/settings/strip_execute', ['repo_name']); |
@@ -4,6 +4,7 b'' | |||
|
4 | 4 | <div class="panel-heading"> |
|
5 | 5 | <h3 class="panel-title">${_('Enable/Disable 2FA for your account')}</h3> |
|
6 | 6 | </div> |
|
7 | ${h.secure_form(h.route_path('my_account_enable_2fa_save'), request=request)} | |
|
7 | 8 | <div class="panel-body"> |
|
8 | 9 | <div class="form"> |
|
9 | 10 | <div class="fields"> |
@@ -12,112 +13,122 b'' | |||
|
12 | 13 | <label>${_('2FA status')}:</label> |
|
13 | 14 | </div> |
|
14 | 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 | 16 | % if c.locked_2fa: |
|
27 | 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 | 27 | % endif |
|
28 | ||
|
29 | 29 | </div> |
|
30 | 30 | </div> |
|
31 | 31 | </div> |
|
32 | 32 | <button id="saveBtn" class="btn btn-primary" ${'disabled' if c.locked_2fa else ''}>${_('Save')}</button> |
|
33 | 33 | </div> |
|
34 | 34 | </div> |
|
35 | ${h.end_form()} | |
|
35 | 36 | </div> |
|
36 | 37 | |
|
37 | 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 | 60 | <div class="panel panel-default"> |
|
39 | 61 | <div class="panel-heading"> |
|
40 | 62 | <h3 class="panel-title">${_('Regenerate 2FA recovery codes for your account')}</h3> |
|
41 | 63 | </div> |
|
42 | 64 | <div class="panel-body"> |
|
43 | 65 | <form id="2faForm"> |
|
44 | <input type="text" name="totp" placeholder="${_('Verify the code from the app')}" pattern="\d{6}" | |
|
45 | style="width: 20%"> | |
|
46 | <button type="button" class="btn btn-primary" onclick="submitForm()">Verify</button> | |
|
66 | <input type="text" name="totp" placeholder="${_('Verify the code from the app')}" pattern="\d{6}" style="width: 20%"> | |
|
67 | <button type="submit" class="btn btn-primary">${_('Verify and generate new codes')}</button> | |
|
47 | 68 | </form> |
|
48 | <div id="result"></div> | |
|
49 | 69 | </div> |
|
50 | 70 | |
|
51 | 71 | </div> |
|
72 | ${h.end_form()} | |
|
73 | % endif | |
|
52 | 74 |
|
|
53 | % endif | |
|
54 | 75 | |
|
55 | 76 | <script> |
|
56 | function submitForm() { | |
|
57 | let formData = new FormData(document.getElementById("2faForm")); | |
|
58 | let xhr = new XMLHttpRequest(); | |
|
77 | ||
|
78 | function showRecoveryCodesPopup() { | |
|
59 | 79 | |
|
60 | let success = function (response) { | |
|
61 | let recovery_codes = response.recovery_codes; | |
|
62 | showRecoveryCodesPopup(recovery_codes); | |
|
63 | } | |
|
80 | SwalNoAnimation.fire({ | |
|
81 | title: _gettext('2FA 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>', | |
|
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 () { | |
|
66 | if (xhr.readyState == 4 && xhr.status == 200) { | |
|
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 | } | |
|
93 | var postData = { | |
|
94 | 'csrf_token': CSRF_TOKEN | |
|
76 | 95 | }; |
|
77 | let url = pyroutes.url('check_2fa'); | |
|
78 | xhr.open("POST", url, true); | |
|
79 | xhr.send(formData); | |
|
96 | return new Promise(function (resolve, reject) { | |
|
97 | $.ajax({ | |
|
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 () { | |
|
83 | document.getElementById('2faDisabled').checked = false; | |
|
84 | }); | |
|
85 | document.getElementById('2faDisabled').addEventListener('click', function () { | |
|
86 | document.getElementById('2faEnabled').checked = false; | |
|
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 | ||
|
113 | }) | |
|
114 | .then(function (result) { | |
|
115 | if (result.value) { | |
|
116 | let funcData = {'recoveryCodes': result.value.recovery_codes} | |
|
117 | let recoveryCodesHtml = renderTemplate('recoveryCodes', funcData); | |
|
114 | 118 | SwalNoAnimation.fire({ |
|
115 | 119 | allowOutsideClick: false, |
|
116 | 120 | confirmButtonText: _gettext('I Copied the codes'), |
|
117 | 121 | title: _gettext('2FA Recovery Codes'), |
|
118 | 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 | 134 | </script> |
@@ -1,7 +1,7 b'' | |||
|
1 | 1 | <%inherit file="base/root.mako"/> |
|
2 | 2 | |
|
3 | 3 | <%def name="title()"> |
|
4 |
${_('Setup |
|
|
4 | ${_('Setup 2FA')} | |
|
5 | 5 | %if c.rhodecode_name: |
|
6 | 6 | · ${h.branding(c.rhodecode_name)} |
|
7 | 7 | %endif |
@@ -22,31 +22,28 b'' | |||
|
22 | 22 | </div> |
|
23 | 23 | |
|
24 | 24 | <div class="loginwrapper"> |
|
25 | <h1>Setup the authenticator app</h1> | |
|
25 | <h1>${_('Setup the authenticator app')}</h1> | |
|
26 | ||
|
26 | 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 | 28 | <rhodecode-toast id="notifications"></rhodecode-toast> |
|
28 | 29 | |
|
29 | 30 | <div id="setup_2fa"> |
|
31 | ${h.secure_form(h.route_path('setup_2fa'), request=request, id='totp_form')} | |
|
30 | 32 | <div class="sign-in-title"> |
|
31 | <h1>${_('Scan the QR code')}</h1> | |
|
33 | <h1>${_('Scan the QR code')}: "${totp_name}"</h1> | |
|
32 | 34 | </div> |
|
33 | <p>Use an authenticator app to scan.</p> | |
|
34 |
<img src="data:image/png;base64, |
|
|
35 | <p>${_('Use an authenticator app to scan.')}</p> | |
|
36 | <img alt="qr-code" src="data:image/png;base64, ${qr}"/> | |
|
37 | ||
|
35 | 38 | <p>${_('Unable to scan?')} <a id="toggleLink">${_('Click here')}</a></p> |
|
36 | 39 | <div id="secretDiv" class="hidden"> |
|
37 | 40 | <p>${_('Copy and use this code to manually setup an authenticator app')}</p> |
|
38 | <input type="text" id="secretField" value=${key}> | |
|
39 |
|
|
|
41 | <input type="text" class="input-monospace" value="${key}" id="secret_totp" name="secret_totp" style="width: 400px"/> | |
|
42 | <i class="tooltip icon-clipboard clipboard-action" data-clipboard-text="${key}" title="${_('Copy the secret key')}"></i> | |
|
40 | 43 | </div> |
|
41 | <div id="codesPopup" class="modal"> | |
|
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> | |
|
44 | ||
|
48 | 45 | <div id="verify_2fa"> |
|
49 | ${h.secure_form(h.route_path('setup_2fa'), request=request, id='totp_form')} | |
|
46 | ||
|
50 | 47 | <div class="form mt-4"> |
|
51 | 48 | <div class="field"> |
|
52 | 49 | <p> |
@@ -65,7 +62,7 b'' | |||
|
65 | 62 | %endif |
|
66 | 63 | </div> |
|
67 | 64 | <div class="input-group-append"> |
|
68 |
${h.submit(' |
|
|
65 | ${h.submit('verify_2fa',_('Verify'),class_="btn btn-primary", style='width: 40%;')} | |
|
69 | 66 | </div> |
|
70 | 67 | </div> |
|
71 | 68 | </div> |
@@ -73,81 +70,18 b'' | |||
|
73 | 70 | </div> |
|
74 | 71 | </div> |
|
75 | 72 | </div> |
|
73 | ${h.end_form()} | |
|
76 | 74 | </div> |
|
77 | 75 | </div> |
|
78 | 76 | </div> |
|
79 | <script> | |
|
80 | document.addEventListener('DOMContentLoaded', function() { | |
|
81 | let clipboardIcons = document.querySelectorAll('.clipboard-action'); | |
|
82 | 77 | |
|
83 | clipboardIcons.forEach(function(icon) { | |
|
84 | icon.addEventListener('click', function() { | |
|
85 | var inputField = document.getElementById('secretField'); | |
|
86 | inputField.select(); | |
|
87 | document.execCommand('copy'); | |
|
78 | <script> | |
|
88 | 79 | |
|
89 | }); | |
|
90 | }); | |
|
91 | }); | |
|
92 | </script> | |
|
93 | <script> | |
|
94 | 80 |
|
|
95 | 81 |
|
|
96 | 82 |
|
|
97 | 83 |
|
|
98 | 84 | } |
|
99 | 85 |
|
|
86 | ||
|
100 | 87 | </script> |
|
101 | <script> | |
|
102 | const recovery_codes_string = '${recovery_codes}'; | |
|
103 | const cleaned_recovery_codes_string = recovery_codes_string | |
|
104 | .replace(/"/g, '"') | |
|
105 | .replace(/'/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=" |
|
|
1 | <%inherit file="base/root.mako"/> | |
|
2 | ||
|
2 | 3 | <%def name="title()"> |
|
3 |
${_(' |
|
|
4 | ${_('Verify 2FA')} | |
|
4 | 5 | %if c.rhodecode_name: |
|
5 | 6 | · ${h.branding(c.rhodecode_name)} |
|
6 | 7 | %endif |
|
7 | 8 | </%def> |
|
9 | <style>body{background-color:#eeeeee;}</style> | |
|
8 | 10 | |
|
9 | <div class="box"> | |
|
10 |
<div class=" |
|
|
11 | <div class="loginbox"> | |
|
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 | 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%;"> | |
|
13 | <div class="field"> | |
|
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"> | |
|
33 | <label for="totp">${_('Verification code')}:</label> | |
|
34 | ${h.text('totp', class_="form-control")} | |
|
24 | 35 |
|
|
25 | 36 |
|
|
26 | 37 |
|
|
27 | 38 |
|
|
28 | </div> | |
|
29 | <br /> | |
|
30 |
${h.submit('s |
|
|
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> | |
|
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 | 46 |
|
|
32 | 47 |
|
|
33 | </p> | |
|
48 | ||
|
34 | 49 |
|
|
35 | 50 |
|
|
36 | </div> | |
|
37 | </div> | |
|
51 | ||
|
52 | ||
|
53 | ||
|
54 |
General Comments 0
You need to be logged in to leave comments.
Login now