Show More
@@ -0,0 +1,67 b'' | |||
|
1 | import pytest | |
|
2 | ||
|
3 | from rhodecode.model.meta import Session | |
|
4 | from rhodecode.tests.fixture import Fixture | |
|
5 | from rhodecode.tests.routes import route_path | |
|
6 | from rhodecode.model.settings import SettingsModel | |
|
7 | ||
|
8 | fixture = Fixture() | |
|
9 | ||
|
10 | ||
|
11 | @pytest.mark.usefixtures('app') | |
|
12 | class Test2FA(object): | |
|
13 | @classmethod | |
|
14 | def setup_class(cls): | |
|
15 | cls.password = 'valid-one' | |
|
16 | ||
|
17 | @classmethod | |
|
18 | def teardown_class(cls): | |
|
19 | SettingsModel().create_or_update_setting('auth_rhodecode_global_2fa', False) | |
|
20 | ||
|
21 | def test_redirect_to_2fa_setup_if_enabled_for_user(self, user_util): | |
|
22 | user = user_util.create_user(password=self.password) | |
|
23 | user.has_enabled_2fa = True | |
|
24 | self.app.post( | |
|
25 | route_path('login'), | |
|
26 | {'username': user.username, | |
|
27 | 'password': self.password}) | |
|
28 | ||
|
29 | response = self.app.get('/') | |
|
30 | assert response.status_code == 302 | |
|
31 | assert response.location.endswith(route_path('setup_2fa')) | |
|
32 | ||
|
33 | def test_redirect_to_2fa_check_if_2fa_configured(self, user_util): | |
|
34 | user = user_util.create_user(password=self.password) | |
|
35 | user.has_enabled_2fa = True | |
|
36 | user.secret_2fa | |
|
37 | Session().add(user) | |
|
38 | Session().commit() | |
|
39 | self.app.post( | |
|
40 | route_path('login'), | |
|
41 | {'username': user.username, | |
|
42 | 'password': self.password}) | |
|
43 | response = self.app.get('/') | |
|
44 | assert response.status_code == 302 | |
|
45 | assert response.location.endswith(route_path('check_2fa')) | |
|
46 | ||
|
47 | def test_2fa_recovery_codes_works_only_once(self, user_util): | |
|
48 | user = user_util.create_user(password=self.password) | |
|
49 | user.has_enabled_2fa = True | |
|
50 | user.secret_2fa | |
|
51 | recovery_cod_to_check = user.get_2fa_recovery_codes()[0] | |
|
52 | Session().add(user) | |
|
53 | Session().commit() | |
|
54 | self.app.post( | |
|
55 | route_path('login'), | |
|
56 | {'username': user.username, | |
|
57 | 'password': self.password}) | |
|
58 | response = self.app.post(route_path('check_2fa'), {'totp': recovery_cod_to_check}) | |
|
59 | assert response.status_code == 302 | |
|
60 | response = self.app.post(route_path('check_2fa'), {'totp': recovery_cod_to_check}) | |
|
61 | response.mustcontain('Code is invalid. Try again!') | |
|
62 | ||
|
63 | def test_2fa_state_when_forced_by_admin(self, user_util): | |
|
64 | user = user_util.create_user(password=self.password) | |
|
65 | user.has_enabled_2fa = False | |
|
66 | SettingsModel().create_or_update_setting('auth_rhodecode_global_2fa', True) | |
|
67 | assert user.has_enabled_2fa |
@@ -0,0 +1,140 b'' | |||
|
1 | <%namespace name="base" file="/base/base.mako"/> | |
|
2 | ||
|
3 | <div class="panel panel-default"> | |
|
4 | <div class="panel-heading"> | |
|
5 | <h3 class="panel-title">${_('Enable/Disable 2FA for your account')}</h3> | |
|
6 | </div> | |
|
7 | <div class="panel-body"> | |
|
8 | <div class="form"> | |
|
9 | <div class="fields"> | |
|
10 | <div class="field"> | |
|
11 | <div class="label"> | |
|
12 | <label>${_('2FA status')}:</label> | |
|
13 | </div> | |
|
14 | <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: | |
|
27 | <span class="help-block">${_('2FA settings cannot be changed here, because 2FA was forced enabled by RhodeCode Administrator.')}</span> | |
|
28 | % endif | |
|
29 | </div> | |
|
30 | </div> | |
|
31 | </div> | |
|
32 | <button id="saveBtn" class="btn btn-primary" ${'disabled' if c.locked_2fa else ''}>${_('Save')}</button> | |
|
33 | </div> | |
|
34 | <div id="codesPopup" class="modal"> | |
|
35 | <div class="modal-content"> | |
|
36 | <ul id="recoveryCodesList"></ul> | |
|
37 | <button id="copyAllBtn" class="btn btn-primary">Copy All</button> | |
|
38 | </div> | |
|
39 | </div> | |
|
40 | </div> | |
|
41 | </div> | |
|
42 | % if c.state_of_2fa: | |
|
43 | <div class="panel panel-default"> | |
|
44 | <div class="panel-heading"> | |
|
45 | <h3 class="panel-title">${_('Regenerate 2FA recovery codes for your account')}</h3> | |
|
46 | </div> | |
|
47 | <div class="panel-body"> | |
|
48 | <form id="2faForm"> | |
|
49 | <input type="text" name="totp" placeholder="${_('Verify the code from the app')}" pattern="\d{6}" | |
|
50 | style="width: 20%"> | |
|
51 | <button type="button" class="btn btn-primary" onclick="submitForm()">Verify</button> | |
|
52 | </form> | |
|
53 | <div id="result"></div> | |
|
54 | </div> | |
|
55 | ||
|
56 | </div> | |
|
57 | % endif | |
|
58 | <script> | |
|
59 | function submitForm() { | |
|
60 | let formData = new FormData(document.getElementById("2faForm")); | |
|
61 | let xhr = new XMLHttpRequest(); | |
|
62 | let success = function (response) { | |
|
63 | let recovery_codes = response.recovery_codes; | |
|
64 | const codesList = document.getElementById("recoveryCodesList"); | |
|
65 | ||
|
66 | codesList.innerHTML = ""; | |
|
67 | recovery_codes.forEach(code => { | |
|
68 | const listItem = document.createElement("li"); | |
|
69 | listItem.textContent = code; | |
|
70 | codesList.appendChild(listItem); | |
|
71 | }); | |
|
72 | } | |
|
73 | xhr.onreadystatechange = function () { | |
|
74 | if (xhr.readyState == 4 && xhr.status == 200) { | |
|
75 | let responseDoc = new DOMParser().parseFromString(xhr.responseText, "text/html"); | |
|
76 | let contentToDisplay = responseDoc.querySelector('#formErrors'); | |
|
77 | if (contentToDisplay) { | |
|
78 | document.getElementById("result").innerHTML = contentToDisplay.innerHTML; | |
|
79 | } else { | |
|
80 | let regenerate_url = pyroutes.url('my_account_regenerate_2fa_recovery_codes'); | |
|
81 | ajaxPOST(regenerate_url, {'csrf_token': CSRF_TOKEN}, success); | |
|
82 | showRecoveryCodesPopup(); | |
|
83 | } | |
|
84 | } | |
|
85 | }; | |
|
86 | let url = pyroutes.url('check_2fa'); | |
|
87 | xhr.open("POST", url, true); | |
|
88 | xhr.send(formData); | |
|
89 | } | |
|
90 | </script> | |
|
91 | <script> | |
|
92 | document.getElementById('2faEnabled').addEventListener('click', function () { | |
|
93 | document.getElementById('2faDisabled').checked = false; | |
|
94 | }); | |
|
95 | document.getElementById('2faDisabled').addEventListener('click', function () { | |
|
96 | document.getElementById('2faEnabled').checked = false; | |
|
97 | }); | |
|
98 | ||
|
99 | function getStateValue() { | |
|
100 | if (document.getElementById('2faEnabled').checked) { | |
|
101 | return '1'; | |
|
102 | } else { | |
|
103 | return '0'; | |
|
104 | } | |
|
105 | }; | |
|
106 | ||
|
107 | function saveChanges(state) { | |
|
108 | ||
|
109 | let post_data = {'state': state, 'csrf_token': CSRF_TOKEN}; | |
|
110 | let url = pyroutes.url('my_account_configure_2fa'); | |
|
111 | ||
|
112 | ajaxPOST(url, post_data, null) | |
|
113 | }; | |
|
114 | document.getElementById('saveBtn').addEventListener('click', function () { | |
|
115 | var state = getStateValue(); | |
|
116 | saveChanges(state); | |
|
117 | }); | |
|
118 | </script> | |
|
119 | <script> | |
|
120 | function showRecoveryCodesPopup() { | |
|
121 | const popup = document.getElementById("codesPopup"); | |
|
122 | popup.style.display = "block"; | |
|
123 | } | |
|
124 | ||
|
125 | document.getElementById("copyAllBtn").addEventListener("click", function () { | |
|
126 | const codesListItems = document.querySelectorAll("#recoveryCodesList li"); | |
|
127 | const allCodes = Array.from(codesListItems).map(item => item.textContent).join(", "); | |
|
128 | ||
|
129 | const textarea = document.createElement('textarea'); | |
|
130 | textarea.value = allCodes; | |
|
131 | document.body.appendChild(textarea); | |
|
132 | ||
|
133 | textarea.select(); | |
|
134 | document.execCommand('copy'); | |
|
135 | ||
|
136 | document.body.removeChild(textarea); | |
|
137 | const popup = document.getElementById("codesPopup"); | |
|
138 | popup.style.display = "" | |
|
139 | }); | |
|
140 | </script> |
@@ -0,0 +1,153 b'' | |||
|
1 | <%inherit file="base/root.mako"/> | |
|
2 | ||
|
3 | <%def name="title()"> | |
|
4 | ${_('Setup authenticator app')} | |
|
5 | %if c.rhodecode_name: | |
|
6 | · ${h.branding(c.rhodecode_name)} | |
|
7 | %endif | |
|
8 | </%def> | |
|
9 | <style>body{background-color:#eeeeee;}</style> | |
|
10 | ||
|
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 | <h1>Setup the authenticator app</h1> | |
|
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 | <rhodecode-toast id="notifications"></rhodecode-toast> | |
|
28 | ||
|
29 | <div id="setup_2fa"> | |
|
30 | <div class="sign-in-title"> | |
|
31 | <h1>${_('Scan the QR code')}</h1> | |
|
32 | </div> | |
|
33 | <p>Use an authenticator app to scan.</p> | |
|
34 | <img src="data:image/png;base64, ${qr}"/> | |
|
35 | <p>${_('Unable to scan?')} <a id="toggleLink">${_('Click here')}</a></p> | |
|
36 | <div id="secretDiv" class="hidden"> | |
|
37 | <p>${_('Copy and use this code to manually setup an authenticator app')}</p> | |
|
38 | <input type="text" id="secretField" value=${key}> | |
|
39 | <i class="tooltip icon-clipboard clipboard-action" data-clipboard-text="" title="${_('Copy the secret key')}"></i> | |
|
40 | </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> | |
|
48 | <div id="verify_2fa"> | |
|
49 | ${h.secure_form(h.route_path('setup_2fa'), request=request, id='totp_form')} | |
|
50 | <div class="form mt-4"> | |
|
51 | <div class="field"> | |
|
52 | <p> | |
|
53 | <div class="label"> | |
|
54 | <label for="totp" class="form-label text-dark font-weight-bold" style="text-align: left;">${_('Verify the code from the app')}:</label> | |
|
55 | </div> | |
|
56 | </p> | |
|
57 | <p> | |
|
58 | <div> | |
|
59 | <div class="input-group"> | |
|
60 | ${h.text('totp', class_='form-control', style='width: 40%;')} | |
|
61 | <div id="formErrors"> | |
|
62 | %if 'totp' in errors: | |
|
63 | <span class="error-message">${errors.get('totp')}</span> | |
|
64 | <br /> | |
|
65 | %endif | |
|
66 | </div> | |
|
67 | <div class="input-group-append"> | |
|
68 | ${h.submit('save',_('Verify'),class_="btn btn-primary", style='width: 40%;', disabled=not codes_viewed)} | |
|
69 | </div> | |
|
70 | </div> | |
|
71 | </div> | |
|
72 | </p> | |
|
73 | </div> | |
|
74 | </div> | |
|
75 | </div> | |
|
76 | </div> | |
|
77 | </div> | |
|
78 | </div> | |
|
79 | <script> | |
|
80 | document.addEventListener('DOMContentLoaded', function() { | |
|
81 | let clipboardIcons = document.querySelectorAll('.clipboard-action'); | |
|
82 | ||
|
83 | clipboardIcons.forEach(function(icon) { | |
|
84 | icon.addEventListener('click', function() { | |
|
85 | var inputField = document.getElementById('secretField'); | |
|
86 | inputField.select(); | |
|
87 | document.execCommand('copy'); | |
|
88 | ||
|
89 | }); | |
|
90 | }); | |
|
91 | }); | |
|
92 | </script> | |
|
93 | <script> | |
|
94 | document.getElementById('toggleLink').addEventListener('click', function() { | |
|
95 | let hiddenField = document.getElementById('secretDiv'); | |
|
96 | if (hiddenField.classList.contains('hidden')) { | |
|
97 | hiddenField.classList.remove('hidden'); | |
|
98 | } | |
|
99 | }); | |
|
100 | </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> |
@@ -0,0 +1,37 b'' | |||
|
1 | <%inherit file="/base/root.mako"/> | |
|
2 | <%def name="title()"> | |
|
3 | ${_('Check 2FA')} | |
|
4 | %if c.rhodecode_name: | |
|
5 | · ${h.branding(c.rhodecode_name)} | |
|
6 | %endif | |
|
7 | </%def> | |
|
8 | ||
|
9 | <div class="box"> | |
|
10 | <div class="verify2FA"> | |
|
11 | ${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"> | |
|
24 | %if 'totp' in errors: | |
|
25 | <span class="error-message">${errors.get('totp')}</span> | |
|
26 | <br /> | |
|
27 | %endif | |
|
28 | </div> | |
|
29 | <br /> | |
|
30 | ${h.submit('save',_('Verify'),class_="btn btn-primary", style='width: 40%;')} | |
|
31 | </div> | |
|
32 | </div> | |
|
33 | </p> | |
|
34 | </div> | |
|
35 | </div> | |
|
36 | </div> | |
|
37 | </div> |
@@ -290,6 +290,7 b' webhelpers2==2.1' | |||
|
290 | 290 | six==1.16.0 |
|
291 | 291 | whoosh==2.7.4 |
|
292 | 292 | zope.cachedescriptors==5.0.0 |
|
293 | qrcode==7.4.2 | |
|
293 | 294 | |
|
294 | 295 | ## uncomment to add the debug libraries |
|
295 | 296 | #-r requirements_debug.txt |
@@ -104,6 +104,11 b' class TemplateArgs(StrictAttributeDict):' | |||
|
104 | 104 | |
|
105 | 105 | |
|
106 | 106 | class BaseAppView(object): |
|
107 | DONT_CHECKOUT_VIEWS = ["channelstream_connect", "ops_ping"] | |
|
108 | EXTRA_VIEWS_TO_IGNORE = ['login', 'register', 'logout'] | |
|
109 | SETUP_2FA_VIEW = 'setup_2fa' | |
|
110 | VERIFY_2FA_VIEW = 'check_2fa' | |
|
111 | ||
|
107 | 112 | def __init__(self, context, request): |
|
108 | 113 | self.request = request |
|
109 | 114 | self.context = context |
@@ -117,13 +122,19 b' class BaseAppView(object):' | |||
|
117 | 122 | |
|
118 | 123 | self._rhodecode_user = request.user # auth user |
|
119 | 124 | self._rhodecode_db_user = self._rhodecode_user.get_instance() |
|
125 | self.user_data = self._rhodecode_db_user.user_data if self._rhodecode_db_user else {} | |
|
120 | 126 | self._maybe_needs_password_change( |
|
121 | 127 | request.matched_route.name, self._rhodecode_db_user |
|
122 | 128 | ) |
|
129 | self._maybe_needs_2fa_configuration( | |
|
130 | request.matched_route.name, self._rhodecode_db_user | |
|
131 | ) | |
|
132 | self._maybe_needs_2fa_check( | |
|
133 | request.matched_route.name, self._rhodecode_db_user | |
|
134 | ) | |
|
123 | 135 | |
|
124 | 136 | def _maybe_needs_password_change(self, view_name, user_obj): |
|
125 | dont_check_views = ["channelstream_connect", "ops_ping"] | |
|
126 | if view_name in dont_check_views: | |
|
137 | if view_name in self.DONT_CHECKOUT_VIEWS: | |
|
127 | 138 | return |
|
128 | 139 | |
|
129 | 140 | log.debug( |
@@ -144,7 +155,7 b' class BaseAppView(object):' | |||
|
144 | 155 | return |
|
145 | 156 | |
|
146 | 157 | now = time.time() |
|
147 |
should_change = |
|
|
158 | should_change = self.user_data.get("force_password_change") | |
|
148 | 159 | change_after = safe_int(should_change) or 0 |
|
149 | 160 | if should_change and now > change_after: |
|
150 | 161 | log.debug("User %s requires password change", user_obj) |
@@ -157,6 +168,36 b' class BaseAppView(object):' | |||
|
157 | 168 | if view_name not in skip_user_views: |
|
158 | 169 | raise HTTPFound(self.request.route_path("my_account_password")) |
|
159 | 170 | |
|
171 | def _maybe_needs_2fa_configuration(self, view_name, user_obj): | |
|
172 | if view_name in self.DONT_CHECKOUT_VIEWS + self.EXTRA_VIEWS_TO_IGNORE: | |
|
173 | return | |
|
174 | ||
|
175 | if not user_obj: | |
|
176 | return | |
|
177 | ||
|
178 | if user_obj.has_forced_2fa and user_obj.extern_type != 'rhodecode': | |
|
179 | return | |
|
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: | |
|
184 | h.flash( | |
|
185 | "You are required to configure 2FA", | |
|
186 | "warning", | |
|
187 | ignore_duplicate=False, | |
|
188 | ) | |
|
189 | raise HTTPFound(self.request.route_path(self.SETUP_2FA_VIEW)) | |
|
190 | ||
|
191 | def _maybe_needs_2fa_check(self, view_name, user_obj): | |
|
192 | if view_name in self.DONT_CHECKOUT_VIEWS + self.EXTRA_VIEWS_TO_IGNORE: | |
|
193 | return | |
|
194 | ||
|
195 | if not user_obj: | |
|
196 | return | |
|
197 | ||
|
198 | if self.user_data.get('check_2fa') and view_name != self.VERIFY_2FA_VIEW: | |
|
199 | raise HTTPFound(self.request.route_path(self.VERIFY_2FA_VIEW)) | |
|
200 | ||
|
160 | 201 | def _log_creation_exception(self, e, repo_name): |
|
161 | 202 | _ = self.request.translate |
|
162 | 203 | reason = None |
@@ -75,3 +75,27 b' def includeme(config):' | |||
|
75 | 75 | LoginView, |
|
76 | 76 | attr='password_reset_confirmation', |
|
77 | 77 | route_name='reset_password_confirmation', request_method='GET') |
|
78 | ||
|
79 | config.add_route( | |
|
80 | name='setup_2fa', | |
|
81 | pattern=ADMIN_PREFIX + '/setup_2fa') | |
|
82 | config.add_view( | |
|
83 | LoginView, | |
|
84 | attr='setup_2fa', | |
|
85 | route_name='setup_2fa', request_method=['GET', 'POST'], | |
|
86 | renderer='rhodecode:templates/configure_2fa.mako') | |
|
87 | ||
|
88 | config.add_route( | |
|
89 | name='check_2fa', | |
|
90 | pattern=ADMIN_PREFIX + '/check_2fa') | |
|
91 | config.add_view( | |
|
92 | LoginView, | |
|
93 | attr='verify_2fa', | |
|
94 | route_name='check_2fa', request_method='GET', | |
|
95 | renderer='rhodecode:templates/verify_2fa.mako') | |
|
96 | config.add_view( | |
|
97 | LoginView, | |
|
98 | attr='verify_2fa', | |
|
99 | route_name='check_2fa', request_method='POST', | |
|
100 | renderer='rhodecode:templates/verify_2fa.mako') | |
|
101 |
@@ -17,6 +17,9 b'' | |||
|
17 | 17 | # and proprietary license terms, please see https://rhodecode.com/licenses/ |
|
18 | 18 | |
|
19 | 19 | import time |
|
20 | import json | |
|
21 | import pyotp | |
|
22 | import qrcode | |
|
20 | 23 | import collections |
|
21 | 24 | import datetime |
|
22 | 25 | import formencode |
@@ -24,7 +27,11 b' import formencode.htmlfill' | |||
|
24 | 27 | import logging |
|
25 | 28 | import urllib.parse |
|
26 | 29 | import requests |
|
30 | from io import BytesIO | |
|
31 | from base64 import b64encode | |
|
27 | 32 | |
|
33 | from pyramid.renderers import render | |
|
34 | from pyramid.response import Response | |
|
28 | 35 | from pyramid.httpexceptions import HTTPFound |
|
29 | 36 | |
|
30 | 37 | |
@@ -35,12 +42,12 b' from rhodecode.events import UserRegiste' | |||
|
35 | 42 | from rhodecode.lib import helpers as h |
|
36 | 43 | from rhodecode.lib import audit_logger |
|
37 | 44 | from rhodecode.lib.auth import ( |
|
38 | AuthUser, HasPermissionAnyDecorator, CSRFRequired) | |
|
45 | AuthUser, HasPermissionAnyDecorator, CSRFRequired, LoginRequired, NotAnonymous) | |
|
39 | 46 | from rhodecode.lib.base import get_ip_addr |
|
40 | 47 | from rhodecode.lib.exceptions import UserCreationError |
|
41 | 48 | from rhodecode.lib.utils2 import safe_str |
|
42 | 49 | from rhodecode.model.db import User, UserApiKeys |
|
43 | from rhodecode.model.forms import LoginForm, RegisterForm, PasswordResetForm | |
|
50 | from rhodecode.model.forms import LoginForm, RegisterForm, PasswordResetForm, TOTPForm | |
|
44 | 51 | from rhodecode.model.meta import Session |
|
45 | 52 | from rhodecode.model.auth_token import AuthTokenModel |
|
46 | 53 | from rhodecode.model.settings import SettingsModel |
@@ -179,9 +186,12 b' class LoginView(BaseAppView):' | |||
|
179 | 186 | self.session.invalidate() |
|
180 | 187 | form_result = login_form.to_python(self.request.POST) |
|
181 | 188 | # form checks for username/password, now we're authenticated |
|
189 | username = form_result['username'] | |
|
190 | if (user := User.get_by_username_or_primary_email(username)).has_enabled_2fa: | |
|
191 | user.update_userdata(check_2fa=True) | |
|
182 | 192 | headers = store_user_in_session( |
|
183 | 193 | self.session, |
|
184 |
user_identifier= |
|
|
194 | user_identifier=username, | |
|
185 | 195 | remember=form_result['remember']) |
|
186 | 196 | log.debug('Redirecting to "%s" after login.', c.came_from) |
|
187 | 197 | |
@@ -436,6 +446,8 b' class LoginView(BaseAppView):' | |||
|
436 | 446 | |
|
437 | 447 | return self._get_template_context(c, **template_context) |
|
438 | 448 | |
|
449 | @LoginRequired() | |
|
450 | @NotAnonymous() | |
|
439 | 451 | def password_reset_confirmation(self): |
|
440 | 452 | self.load_default_context() |
|
441 | 453 | if self.request.GET and self.request.GET.get('key'): |
@@ -467,3 +479,63 b' class LoginView(BaseAppView):' | |||
|
467 | 479 | return HTTPFound(self.request.route_path('reset_password')) |
|
468 | 480 | |
|
469 | 481 | return HTTPFound(self.request.route_path('login')) |
|
482 | ||
|
483 | @LoginRequired() | |
|
484 | @NotAnonymous() | |
|
485 | def setup_2fa(self): | |
|
486 | _ = self.request.translate | |
|
487 | c = self.load_default_context() | |
|
488 | user_instance = self._rhodecode_db_user | |
|
489 | form = TOTPForm(_, user_instance)() | |
|
490 | render_ctx = {} | |
|
491 | if self.request.method == 'POST': | |
|
492 | try: | |
|
493 | form.to_python(dict(self.request.POST)) | |
|
494 | Session().commit() | |
|
495 | raise HTTPFound(c.came_from) | |
|
496 | except formencode.Invalid as errors: | |
|
497 | defaults = errors.value | |
|
498 | render_ctx = { | |
|
499 | 'errors': errors.error_dict, | |
|
500 | 'defaults': defaults, | |
|
501 | } | |
|
502 | 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)) | |
|
509 | qr.make(fit=True) | |
|
510 | img = qr.make_image(fill_color='black', back_color='white') | |
|
511 | buffered = BytesIO() | |
|
512 | img.save(buffered) | |
|
513 | return self._get_template_context( | |
|
514 | c, | |
|
515 | qr=b64encode(buffered.getvalue()).decode("utf-8"), | |
|
516 | key=secret, recovery_codes=json.dumps(recovery_codes), | |
|
517 | codes_viewed=not bool(recovery_codes), | |
|
518 | ** render_ctx | |
|
519 | ) | |
|
520 | ||
|
521 | @LoginRequired() | |
|
522 | @NotAnonymous() | |
|
523 | def verify_2fa(self): | |
|
524 | _ = self.request.translate | |
|
525 | c = self.load_default_context() | |
|
526 | render_ctx = {} | |
|
527 | user_instance = self._rhodecode_db_user | |
|
528 | totp_form = TOTPForm(_, user_instance, allow_recovery_code_use=True)() | |
|
529 | if self.request.method == 'POST': | |
|
530 | try: | |
|
531 | totp_form.to_python(dict(self.request.POST)) | |
|
532 | user_instance.update_userdata(check_2fa=False) | |
|
533 | Session().commit() | |
|
534 | raise HTTPFound(c.came_from) | |
|
535 | except formencode.Invalid as errors: | |
|
536 | defaults = errors.value | |
|
537 | render_ctx = { | |
|
538 | 'errors': errors.error_dict, | |
|
539 | 'defaults': defaults, | |
|
540 | } | |
|
541 | return self._get_template_context(c, **render_ctx) |
@@ -74,6 +74,34 b' def includeme(config):' | |||
|
74 | 74 | route_name='my_account_password_update', request_method='POST', |
|
75 | 75 | renderer='rhodecode:templates/admin/my_account/my_account.mako') |
|
76 | 76 | |
|
77 | # my account 2fa | |
|
78 | config.add_route( | |
|
79 | name='my_account_enable_2fa', | |
|
80 | pattern=ADMIN_PREFIX + '/my_account/enable_2fa') | |
|
81 | config.add_view( | |
|
82 | MyAccountView, | |
|
83 | attr='my_account_2fa', | |
|
84 | route_name='my_account_enable_2fa', request_method='GET', | |
|
85 | renderer='rhodecode:templates/admin/my_account/my_account.mako') | |
|
86 | ||
|
87 | config.add_route( | |
|
88 | name='my_account_configure_2fa', | |
|
89 | pattern=ADMIN_PREFIX + '/my_account/configure_2fa') | |
|
90 | config.add_view( | |
|
91 | MyAccountView, | |
|
92 | attr='my_account_2fa_configure', | |
|
93 | route_name='my_account_configure_2fa', request_method='POST', xhr=True, | |
|
94 | renderer='json_ext') | |
|
95 | ||
|
96 | config.add_route( | |
|
97 | name='my_account_regenerate_2fa_recovery_codes', | |
|
98 | pattern=ADMIN_PREFIX + '/my_account/regenerate_recovery_codes') | |
|
99 | config.add_view( | |
|
100 | MyAccountView, | |
|
101 | attr='my_account_2fa_regenerate_recovery_codes', | |
|
102 | route_name='my_account_regenerate_2fa_recovery_codes', request_method='POST', xhr=True, | |
|
103 | renderer='json_ext') | |
|
104 | ||
|
77 | 105 | # my account tokens |
|
78 | 106 | config.add_route( |
|
79 | 107 | name='my_account_auth_tokens', |
@@ -204,6 +204,33 b' class MyAccountView(BaseAppView, DataGri' | |||
|
204 | 204 | |
|
205 | 205 | @LoginRequired() |
|
206 | 206 | @NotAnonymous() |
|
207 | def my_account_2fa(self): | |
|
208 | _ = self.request.translate | |
|
209 | c = self.load_default_context() | |
|
210 | c.active = '2fa' | |
|
211 | from rhodecode.model.settings import SettingsModel | |
|
212 | user_instance = self._rhodecode_db_user | |
|
213 | locked_by_admin = user_instance.has_forced_2fa | |
|
214 | c.state_of_2fa = user_instance.has_enabled_2fa | |
|
215 | c.locked_2fa = str2bool(locked_by_admin) | |
|
216 | return self._get_template_context(c) | |
|
217 | ||
|
218 | @LoginRequired() | |
|
219 | @NotAnonymous() | |
|
220 | @CSRFRequired() | |
|
221 | def my_account_2fa_configure(self): | |
|
222 | state = self.request.POST.get('state') | |
|
223 | self._rhodecode_db_user.has_enabled_2fa = state | |
|
224 | return {'state_of_2fa': state} | |
|
225 | ||
|
226 | @LoginRequired() | |
|
227 | @NotAnonymous() | |
|
228 | @CSRFRequired() | |
|
229 | def my_account_2fa_regenerate_recovery_codes(self): | |
|
230 | return {'recovery_codes': self._rhodecode_db_user.regenerate_2fa_recovery_codes()} | |
|
231 | ||
|
232 | @LoginRequired() | |
|
233 | @NotAnonymous() | |
|
207 | 234 | def my_account_auth_tokens(self): |
|
208 | 235 | _ = self.request.translate |
|
209 | 236 |
@@ -183,6 +183,14 b' class RhodeCodeAuthPlugin(RhodeCodeAuthP' | |||
|
183 | 183 | |
|
184 | 184 | |
|
185 | 185 | class RhodeCodeSettingsSchema(AuthnPluginSettingsSchemaBase): |
|
186 | global_2fa = colander.SchemaNode( | |
|
187 | colander.Bool(), | |
|
188 | default=False, | |
|
189 | description=_('Force all users to use two factor authentication by enabling this.'), | |
|
190 | missing=False, | |
|
191 | title=_('Global 2FA'), | |
|
192 | widget='bool', | |
|
193 | ) | |
|
186 | 194 | |
|
187 | 195 | auth_restriction_choices = [ |
|
188 | 196 | (RhodeCodeAuthPlugin.AUTH_RESTRICTION_NONE, 'All users'), |
@@ -33,6 +33,7 b' import functools' | |||
|
33 | 33 | import traceback |
|
34 | 34 | import collections |
|
35 | 35 | |
|
36 | import pyotp | |
|
36 | 37 | from sqlalchemy import ( |
|
37 | 38 | or_, and_, not_, func, cast, TypeDecorator, event, select, |
|
38 | 39 | true, false, null, union_all, |
@@ -51,6 +52,7 b' from zope.cachedescriptors.property impo' | |||
|
51 | 52 | from pyramid.threadlocal import get_current_request |
|
52 | 53 | from webhelpers2.text import remove_formatting |
|
53 | 54 | |
|
55 | from rhodecode import ConfigGet | |
|
54 | 56 | from rhodecode.lib.str_utils import safe_bytes |
|
55 | 57 | from rhodecode.translation import _ |
|
56 | 58 | from rhodecode.lib.vcs import get_vcs_instance, VCSError |
@@ -586,6 +588,7 b' class User(Base, BaseModel):' | |||
|
586 | 588 | DEFAULT_USER = 'default' |
|
587 | 589 | DEFAULT_USER_EMAIL = 'anonymous@rhodecode.org' |
|
588 | 590 | DEFAULT_GRAVATAR_URL = 'https://secure.gravatar.com/avatar/{md5email}?d=identicon&s={size}' |
|
591 | RECOVERY_CODES_COUNT = 10 | |
|
589 | 592 | |
|
590 | 593 | user_id = Column("user_id", Integer(), nullable=False, unique=True, default=None, primary_key=True) |
|
591 | 594 | username = Column("username", String(255), nullable=True, unique=None, default=None) |
@@ -793,6 +796,94 b' class User(Base, BaseModel):' | |||
|
793 | 796 | Session.commit() |
|
794 | 797 | return artifact_token.api_key |
|
795 | 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) | |
|
815 | return totp.verify(received_code) | |
|
816 | ||
|
817 | def is_2fa_recovery_code_valid(self, received_code): | |
|
818 | encrypted_recovery_codes = self.user_data.get('recovery_codes_2fa', []) | |
|
819 | recovery_codes = list(map( | |
|
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: | |
|
828 | encrypted_recovery_codes.pop(recovery_codes.index(received_code)) | |
|
829 | self.update_userdata(recovery_codes_2fa=encrypted_recovery_codes) | |
|
830 | return True | |
|
831 | return False | |
|
832 | ||
|
833 | @hybrid_property | |
|
834 | def has_forced_2fa(self): | |
|
835 | """ | |
|
836 | Checks if 2fa was forced for ALL users (including current one) | |
|
837 | """ | |
|
838 | from rhodecode.model.settings import SettingsModel | |
|
839 | # So now we're supporting only auth_rhodecode_global_2f | |
|
840 | if value := SettingsModel().get_setting_by_name('auth_rhodecode_global_2fa'): | |
|
841 | return value.app_settings_value | |
|
842 | return False | |
|
843 | ||
|
844 | @hybrid_property | |
|
845 | def has_enabled_2fa(self): | |
|
846 | """ | |
|
847 | Checks if 2fa was enabled by user | |
|
848 | """ | |
|
849 | if value := self.has_forced_2fa: | |
|
850 | return value | |
|
851 | return self.user_data.get('enabled_2fa', False) | |
|
852 | ||
|
853 | @has_enabled_2fa.setter | |
|
854 | def has_enabled_2fa(self, val): | |
|
855 | val = str2bool(val) | |
|
856 | self.update_userdata(enabled_2fa=str2bool(val)) | |
|
857 | if not val: | |
|
858 | self.update_userdata(secret_2fa=None, recovery_codes_2fa=[]) | |
|
859 | Session().commit() | |
|
860 | ||
|
861 | def get_2fa_recovery_codes(self): | |
|
862 | """ | |
|
863 | Creates 2fa recovery codes | |
|
864 | """ | |
|
865 | recovery_codes = self.user_data.get('recovery_codes_2fa', []) | |
|
866 | encrypted_codes = [] | |
|
867 | if not recovery_codes: | |
|
868 | for _ in range(self.RECOVERY_CODES_COUNT): | |
|
869 | recovery_code = pyotp.random_base32() | |
|
870 | recovery_codes.append(recovery_code) | |
|
871 | encrypted_codes.append(safe_str(enc_utils.encrypt_value(recovery_code, enc_key=ENCRYPTION_KEY))) | |
|
872 | self.update_userdata(recovery_codes_2fa=encrypted_codes) | |
|
873 | return recovery_codes | |
|
874 | # User should not check the same recovery codes more than once | |
|
875 | return [] | |
|
876 | ||
|
877 | def regenerate_2fa_recovery_codes(self): | |
|
878 | """ | |
|
879 | Regenerates 2fa recovery codes upon request | |
|
880 | """ | |
|
881 | self.update_userdata(recovery_codes_2fa=[]) | |
|
882 | Session().flush() | |
|
883 | new_recovery_codes = self.get_2fa_recovery_codes() | |
|
884 | Session().commit() | |
|
885 | return new_recovery_codes | |
|
886 | ||
|
796 | 887 | @classmethod |
|
797 | 888 | def get(cls, user_id, cache=False): |
|
798 | 889 | if not user_id: |
@@ -104,6 +104,28 b' def LoginForm(localizer):' | |||
|
104 | 104 | return _LoginForm |
|
105 | 105 | |
|
106 | 106 | |
|
107 | def TOTPForm(localizer, user, allow_recovery_code_use=False): | |
|
108 | _ = localizer | |
|
109 | ||
|
110 | class _TOTPForm(formencode.Schema): | |
|
111 | allow_extra_fields = True | |
|
112 | filter_extra_fields = False | |
|
113 | totp = v.Regex(r'^(?:\d{6}|[A-Z0-9]{32})$') | |
|
114 | ||
|
115 | def to_python(self, value, state=None): | |
|
116 | validation_checks = [user.is_totp_valid] | |
|
117 | if allow_recovery_code_use: | |
|
118 | validation_checks.append(user.is_2fa_recovery_code_valid) | |
|
119 | form_data = super().to_python(value, state) | |
|
120 | received_code = form_data['totp'] | |
|
121 | if not any(map(lambda x: x(received_code), validation_checks)): | |
|
122 | error_msg = _('Code is invalid. Try again!') | |
|
123 | raise formencode.Invalid(error_msg, v, state, error_dict={'totp': error_msg}) | |
|
124 | return True | |
|
125 | ||
|
126 | return _TOTPForm | |
|
127 | ||
|
128 | ||
|
107 | 129 | def UserForm(localizer, edit=False, available_languages=None, old_data=None): |
|
108 | 130 | old_data = old_data or {} |
|
109 | 131 | available_languages = available_languages or [] |
@@ -224,6 +224,9 b' function registerRCRoutes() {' | |||
|
224 | 224 | pyroutes.register('my_account_notifications', '/_admin/my_account/notifications', []); |
|
225 | 225 | pyroutes.register('my_account_notifications_test_channelstream', '/_admin/my_account/test_channelstream', []); |
|
226 | 226 | 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', []); | |
|
227 | 230 | pyroutes.register('my_account_password', '/_admin/my_account/password', []); |
|
228 | 231 | pyroutes.register('my_account_password_update', '/_admin/my_account/password/update', []); |
|
229 | 232 | pyroutes.register('my_account_perms', '/_admin/my_account/perms', []); |
@@ -28,6 +28,7 b'' | |||
|
28 | 28 | <li class="${h.is_active(['profile', 'profile_edit'], c.active)}"><a href="${h.route_path('my_account_profile')}">${_('Profile')}</a></li> |
|
29 | 29 | <li class="${h.is_active('emails', c.active)}"><a href="${h.route_path('my_account_emails')}">${_('Emails')}</a></li> |
|
30 | 30 | <li class="${h.is_active('password', c.active)}"><a href="${h.route_path('my_account_password')}">${_('Password')}</a></li> |
|
31 | <li class="${h.is_active('2FA', c.active)}"><a href="${h.route_path('my_account_enable_2fa')}">${_('2FA')}</a></li> | |
|
31 | 32 | <li class="${h.is_active('bookmarks', c.active)}"><a href="${h.route_path('my_account_bookmarks')}">${_('Bookmarks')}</a></li> |
|
32 | 33 | <li class="${h.is_active('auth_tokens', c.active)}"><a href="${h.route_path('my_account_auth_tokens')}">${_('Auth Tokens')}</a></li> |
|
33 | 34 | <li class="${h.is_active(['ssh_keys', 'ssh_keys_generate'], c.active)}"><a href="${h.route_path('my_account_ssh_keys')}">${_('SSH Keys')}</a></li> |
@@ -106,6 +106,7 b' def get_url_defs():' | |||
|
106 | 106 | + "/gists/{gist_id}/rev/{revision}/{format}/{f_path}", |
|
107 | 107 | "login": ADMIN_PREFIX + "/login", |
|
108 | 108 | "logout": ADMIN_PREFIX + "/logout", |
|
109 | "check_2fa": ADMIN_PREFIX + "/check_2fa", | |
|
109 | 110 | "register": ADMIN_PREFIX + "/register", |
|
110 | 111 | "reset_password": ADMIN_PREFIX + "/password_reset", |
|
111 | 112 | "reset_password_confirmation": ADMIN_PREFIX + "/password_reset_confirmation", |
General Comments 0
You need to be logged in to leave comments.
Login now