##// END OF EJS Templates
feat(2fa): Added 2fa option. Fixes: RCCE-65
ilin.s -
r5360:4cbf1ad2 default
parent child Browse files
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 &middot; ${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(/&#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>
@@ -0,0 +1,37 b''
1 <%inherit file="/base/root.mako"/>
2 <%def name="title()">
3 ${_('Check 2FA')}
4 %if c.rhodecode_name:
5 &middot; ${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 = user_obj.user_data.get("force_password_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=form_result['username'],
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