feat(2fa): Added 2fa option. Fixes: RCCE-65
This commit is contained in:
parent
31e3c38a18
commit
9678665ecd
16 changed files with 722 additions and 6 deletions
|
|
@ -290,6 +290,7 @@ webhelpers2==2.1
|
|||
six==1.16.0
|
||||
whoosh==2.7.4
|
||||
zope.cachedescriptors==5.0.0
|
||||
qrcode==7.4.2
|
||||
|
||||
## uncomment to add the debug libraries
|
||||
#-r requirements_debug.txt
|
||||
|
|
|
|||
|
|
@ -104,6 +104,11 @@ class TemplateArgs(StrictAttributeDict):
|
|||
|
||||
|
||||
class BaseAppView(object):
|
||||
DONT_CHECKOUT_VIEWS = ["channelstream_connect", "ops_ping"]
|
||||
EXTRA_VIEWS_TO_IGNORE = ['login', 'register', 'logout']
|
||||
SETUP_2FA_VIEW = 'setup_2fa'
|
||||
VERIFY_2FA_VIEW = 'check_2fa'
|
||||
|
||||
def __init__(self, context, request):
|
||||
self.request = request
|
||||
self.context = context
|
||||
|
|
@ -117,13 +122,19 @@ class BaseAppView(object):
|
|||
|
||||
self._rhodecode_user = request.user # auth user
|
||||
self._rhodecode_db_user = self._rhodecode_user.get_instance()
|
||||
self.user_data = self._rhodecode_db_user.user_data if self._rhodecode_db_user else {}
|
||||
self._maybe_needs_password_change(
|
||||
request.matched_route.name, self._rhodecode_db_user
|
||||
)
|
||||
self._maybe_needs_2fa_configuration(
|
||||
request.matched_route.name, self._rhodecode_db_user
|
||||
)
|
||||
self._maybe_needs_2fa_check(
|
||||
request.matched_route.name, self._rhodecode_db_user
|
||||
)
|
||||
|
||||
def _maybe_needs_password_change(self, view_name, user_obj):
|
||||
dont_check_views = ["channelstream_connect", "ops_ping"]
|
||||
if view_name in dont_check_views:
|
||||
if view_name in self.DONT_CHECKOUT_VIEWS:
|
||||
return
|
||||
|
||||
log.debug(
|
||||
|
|
@ -144,7 +155,7 @@ class BaseAppView(object):
|
|||
return
|
||||
|
||||
now = time.time()
|
||||
should_change = user_obj.user_data.get("force_password_change")
|
||||
should_change = self.user_data.get("force_password_change")
|
||||
change_after = safe_int(should_change) or 0
|
||||
if should_change and now > change_after:
|
||||
log.debug("User %s requires password change", user_obj)
|
||||
|
|
@ -157,6 +168,36 @@ class BaseAppView(object):
|
|||
if view_name not in skip_user_views:
|
||||
raise HTTPFound(self.request.route_path("my_account_password"))
|
||||
|
||||
def _maybe_needs_2fa_configuration(self, view_name, user_obj):
|
||||
if view_name in self.DONT_CHECKOUT_VIEWS + self.EXTRA_VIEWS_TO_IGNORE:
|
||||
return
|
||||
|
||||
if not user_obj:
|
||||
return
|
||||
|
||||
if user_obj.has_forced_2fa and user_obj.extern_type != 'rhodecode':
|
||||
return
|
||||
|
||||
if (user_obj.has_enabled_2fa
|
||||
and not self.user_data.get('secret_2fa')) \
|
||||
and view_name != self.SETUP_2FA_VIEW:
|
||||
h.flash(
|
||||
"You are required to configure 2FA",
|
||||
"warning",
|
||||
ignore_duplicate=False,
|
||||
)
|
||||
raise HTTPFound(self.request.route_path(self.SETUP_2FA_VIEW))
|
||||
|
||||
def _maybe_needs_2fa_check(self, view_name, user_obj):
|
||||
if view_name in self.DONT_CHECKOUT_VIEWS + self.EXTRA_VIEWS_TO_IGNORE:
|
||||
return
|
||||
|
||||
if not user_obj:
|
||||
return
|
||||
|
||||
if self.user_data.get('check_2fa') and view_name != self.VERIFY_2FA_VIEW:
|
||||
raise HTTPFound(self.request.route_path(self.VERIFY_2FA_VIEW))
|
||||
|
||||
def _log_creation_exception(self, e, repo_name):
|
||||
_ = self.request.translate
|
||||
reason = None
|
||||
|
|
|
|||
|
|
@ -75,3 +75,27 @@ def includeme(config):
|
|||
LoginView,
|
||||
attr='password_reset_confirmation',
|
||||
route_name='reset_password_confirmation', request_method='GET')
|
||||
|
||||
config.add_route(
|
||||
name='setup_2fa',
|
||||
pattern=ADMIN_PREFIX + '/setup_2fa')
|
||||
config.add_view(
|
||||
LoginView,
|
||||
attr='setup_2fa',
|
||||
route_name='setup_2fa', request_method=['GET', 'POST'],
|
||||
renderer='rhodecode:templates/configure_2fa.mako')
|
||||
|
||||
config.add_route(
|
||||
name='check_2fa',
|
||||
pattern=ADMIN_PREFIX + '/check_2fa')
|
||||
config.add_view(
|
||||
LoginView,
|
||||
attr='verify_2fa',
|
||||
route_name='check_2fa', request_method='GET',
|
||||
renderer='rhodecode:templates/verify_2fa.mako')
|
||||
config.add_view(
|
||||
LoginView,
|
||||
attr='verify_2fa',
|
||||
route_name='check_2fa', request_method='POST',
|
||||
renderer='rhodecode:templates/verify_2fa.mako')
|
||||
|
||||
|
|
|
|||
67
rhodecode/apps/login/tests/test_2fa.py
Normal file
67
rhodecode/apps/login/tests/test_2fa.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import pytest
|
||||
|
||||
from rhodecode.model.meta import Session
|
||||
from rhodecode.tests.fixture import Fixture
|
||||
from rhodecode.tests.routes import route_path
|
||||
from rhodecode.model.settings import SettingsModel
|
||||
|
||||
fixture = Fixture()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('app')
|
||||
class Test2FA(object):
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
cls.password = 'valid-one'
|
||||
|
||||
@classmethod
|
||||
def teardown_class(cls):
|
||||
SettingsModel().create_or_update_setting('auth_rhodecode_global_2fa', False)
|
||||
|
||||
def test_redirect_to_2fa_setup_if_enabled_for_user(self, user_util):
|
||||
user = user_util.create_user(password=self.password)
|
||||
user.has_enabled_2fa = True
|
||||
self.app.post(
|
||||
route_path('login'),
|
||||
{'username': user.username,
|
||||
'password': self.password})
|
||||
|
||||
response = self.app.get('/')
|
||||
assert response.status_code == 302
|
||||
assert response.location.endswith(route_path('setup_2fa'))
|
||||
|
||||
def test_redirect_to_2fa_check_if_2fa_configured(self, user_util):
|
||||
user = user_util.create_user(password=self.password)
|
||||
user.has_enabled_2fa = True
|
||||
user.secret_2fa
|
||||
Session().add(user)
|
||||
Session().commit()
|
||||
self.app.post(
|
||||
route_path('login'),
|
||||
{'username': user.username,
|
||||
'password': self.password})
|
||||
response = self.app.get('/')
|
||||
assert response.status_code == 302
|
||||
assert response.location.endswith(route_path('check_2fa'))
|
||||
|
||||
def test_2fa_recovery_codes_works_only_once(self, user_util):
|
||||
user = user_util.create_user(password=self.password)
|
||||
user.has_enabled_2fa = True
|
||||
user.secret_2fa
|
||||
recovery_cod_to_check = user.get_2fa_recovery_codes()[0]
|
||||
Session().add(user)
|
||||
Session().commit()
|
||||
self.app.post(
|
||||
route_path('login'),
|
||||
{'username': user.username,
|
||||
'password': self.password})
|
||||
response = self.app.post(route_path('check_2fa'), {'totp': recovery_cod_to_check})
|
||||
assert response.status_code == 302
|
||||
response = self.app.post(route_path('check_2fa'), {'totp': recovery_cod_to_check})
|
||||
response.mustcontain('Code is invalid. Try again!')
|
||||
|
||||
def test_2fa_state_when_forced_by_admin(self, user_util):
|
||||
user = user_util.create_user(password=self.password)
|
||||
user.has_enabled_2fa = False
|
||||
SettingsModel().create_or_update_setting('auth_rhodecode_global_2fa', True)
|
||||
assert user.has_enabled_2fa
|
||||
|
|
@ -17,6 +17,9 @@
|
|||
# and proprietary license terms, please see https://rhodecode.com/licenses/
|
||||
|
||||
import time
|
||||
import json
|
||||
import pyotp
|
||||
import qrcode
|
||||
import collections
|
||||
import datetime
|
||||
import formencode
|
||||
|
|
@ -24,7 +27,11 @@ import formencode.htmlfill
|
|||
import logging
|
||||
import urllib.parse
|
||||
import requests
|
||||
from io import BytesIO
|
||||
from base64 import b64encode
|
||||
|
||||
from pyramid.renderers import render
|
||||
from pyramid.response import Response
|
||||
from pyramid.httpexceptions import HTTPFound
|
||||
|
||||
|
||||
|
|
@ -35,12 +42,12 @@ from rhodecode.events import UserRegistered, trigger
|
|||
from rhodecode.lib import helpers as h
|
||||
from rhodecode.lib import audit_logger
|
||||
from rhodecode.lib.auth import (
|
||||
AuthUser, HasPermissionAnyDecorator, CSRFRequired)
|
||||
AuthUser, HasPermissionAnyDecorator, CSRFRequired, LoginRequired, NotAnonymous)
|
||||
from rhodecode.lib.base import get_ip_addr
|
||||
from rhodecode.lib.exceptions import UserCreationError
|
||||
from rhodecode.lib.utils2 import safe_str
|
||||
from rhodecode.model.db import User, UserApiKeys
|
||||
from rhodecode.model.forms import LoginForm, RegisterForm, PasswordResetForm
|
||||
from rhodecode.model.forms import LoginForm, RegisterForm, PasswordResetForm, TOTPForm
|
||||
from rhodecode.model.meta import Session
|
||||
from rhodecode.model.auth_token import AuthTokenModel
|
||||
from rhodecode.model.settings import SettingsModel
|
||||
|
|
@ -179,9 +186,12 @@ class LoginView(BaseAppView):
|
|||
self.session.invalidate()
|
||||
form_result = login_form.to_python(self.request.POST)
|
||||
# form checks for username/password, now we're authenticated
|
||||
username = form_result['username']
|
||||
if (user := User.get_by_username_or_primary_email(username)).has_enabled_2fa:
|
||||
user.update_userdata(check_2fa=True)
|
||||
headers = store_user_in_session(
|
||||
self.session,
|
||||
user_identifier=form_result['username'],
|
||||
user_identifier=username,
|
||||
remember=form_result['remember'])
|
||||
log.debug('Redirecting to "%s" after login.', c.came_from)
|
||||
|
||||
|
|
@ -436,6 +446,8 @@ class LoginView(BaseAppView):
|
|||
|
||||
return self._get_template_context(c, **template_context)
|
||||
|
||||
@LoginRequired()
|
||||
@NotAnonymous()
|
||||
def password_reset_confirmation(self):
|
||||
self.load_default_context()
|
||||
if self.request.GET and self.request.GET.get('key'):
|
||||
|
|
@ -467,3 +479,63 @@ class LoginView(BaseAppView):
|
|||
return HTTPFound(self.request.route_path('reset_password'))
|
||||
|
||||
return HTTPFound(self.request.route_path('login'))
|
||||
|
||||
@LoginRequired()
|
||||
@NotAnonymous()
|
||||
def setup_2fa(self):
|
||||
_ = self.request.translate
|
||||
c = self.load_default_context()
|
||||
user_instance = self._rhodecode_db_user
|
||||
form = TOTPForm(_, user_instance)()
|
||||
render_ctx = {}
|
||||
if self.request.method == 'POST':
|
||||
try:
|
||||
form.to_python(dict(self.request.POST))
|
||||
Session().commit()
|
||||
raise HTTPFound(c.came_from)
|
||||
except formencode.Invalid as errors:
|
||||
defaults = errors.value
|
||||
render_ctx = {
|
||||
'errors': errors.error_dict,
|
||||
'defaults': defaults,
|
||||
}
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||||
secret = user_instance.secret_2fa
|
||||
Session().flush()
|
||||
recovery_codes = user_instance.get_2fa_recovery_codes()
|
||||
Session().commit()
|
||||
qr.add_data(pyotp.totp.TOTP(secret).provisioning_uri(
|
||||
name=self.request.user.name))
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color='black', back_color='white')
|
||||
buffered = BytesIO()
|
||||
img.save(buffered)
|
||||
return self._get_template_context(
|
||||
c,
|
||||
qr=b64encode(buffered.getvalue()).decode("utf-8"),
|
||||
key=secret, recovery_codes=json.dumps(recovery_codes),
|
||||
codes_viewed=not bool(recovery_codes),
|
||||
** render_ctx
|
||||
)
|
||||
|
||||
@LoginRequired()
|
||||
@NotAnonymous()
|
||||
def verify_2fa(self):
|
||||
_ = self.request.translate
|
||||
c = self.load_default_context()
|
||||
render_ctx = {}
|
||||
user_instance = self._rhodecode_db_user
|
||||
totp_form = TOTPForm(_, user_instance, allow_recovery_code_use=True)()
|
||||
if self.request.method == 'POST':
|
||||
try:
|
||||
totp_form.to_python(dict(self.request.POST))
|
||||
user_instance.update_userdata(check_2fa=False)
|
||||
Session().commit()
|
||||
raise HTTPFound(c.came_from)
|
||||
except formencode.Invalid as errors:
|
||||
defaults = errors.value
|
||||
render_ctx = {
|
||||
'errors': errors.error_dict,
|
||||
'defaults': defaults,
|
||||
}
|
||||
return self._get_template_context(c, **render_ctx)
|
||||
|
|
|
|||
|
|
@ -74,6 +74,34 @@ def includeme(config):
|
|||
route_name='my_account_password_update', request_method='POST',
|
||||
renderer='rhodecode:templates/admin/my_account/my_account.mako')
|
||||
|
||||
# my account 2fa
|
||||
config.add_route(
|
||||
name='my_account_enable_2fa',
|
||||
pattern=ADMIN_PREFIX + '/my_account/enable_2fa')
|
||||
config.add_view(
|
||||
MyAccountView,
|
||||
attr='my_account_2fa',
|
||||
route_name='my_account_enable_2fa', request_method='GET',
|
||||
renderer='rhodecode:templates/admin/my_account/my_account.mako')
|
||||
|
||||
config.add_route(
|
||||
name='my_account_configure_2fa',
|
||||
pattern=ADMIN_PREFIX + '/my_account/configure_2fa')
|
||||
config.add_view(
|
||||
MyAccountView,
|
||||
attr='my_account_2fa_configure',
|
||||
route_name='my_account_configure_2fa', request_method='POST', xhr=True,
|
||||
renderer='json_ext')
|
||||
|
||||
config.add_route(
|
||||
name='my_account_regenerate_2fa_recovery_codes',
|
||||
pattern=ADMIN_PREFIX + '/my_account/regenerate_recovery_codes')
|
||||
config.add_view(
|
||||
MyAccountView,
|
||||
attr='my_account_2fa_regenerate_recovery_codes',
|
||||
route_name='my_account_regenerate_2fa_recovery_codes', request_method='POST', xhr=True,
|
||||
renderer='json_ext')
|
||||
|
||||
# my account tokens
|
||||
config.add_route(
|
||||
name='my_account_auth_tokens',
|
||||
|
|
|
|||
|
|
@ -202,6 +202,33 @@ class MyAccountView(BaseAppView, DataGridAppView):
|
|||
|
||||
raise HTTPFound(self.request.route_path('my_account_password'))
|
||||
|
||||
@LoginRequired()
|
||||
@NotAnonymous()
|
||||
def my_account_2fa(self):
|
||||
_ = self.request.translate
|
||||
c = self.load_default_context()
|
||||
c.active = '2fa'
|
||||
from rhodecode.model.settings import SettingsModel
|
||||
user_instance = self._rhodecode_db_user
|
||||
locked_by_admin = user_instance.has_forced_2fa
|
||||
c.state_of_2fa = user_instance.has_enabled_2fa
|
||||
c.locked_2fa = str2bool(locked_by_admin)
|
||||
return self._get_template_context(c)
|
||||
|
||||
@LoginRequired()
|
||||
@NotAnonymous()
|
||||
@CSRFRequired()
|
||||
def my_account_2fa_configure(self):
|
||||
state = self.request.POST.get('state')
|
||||
self._rhodecode_db_user.has_enabled_2fa = state
|
||||
return {'state_of_2fa': state}
|
||||
|
||||
@LoginRequired()
|
||||
@NotAnonymous()
|
||||
@CSRFRequired()
|
||||
def my_account_2fa_regenerate_recovery_codes(self):
|
||||
return {'recovery_codes': self._rhodecode_db_user.regenerate_2fa_recovery_codes()}
|
||||
|
||||
@LoginRequired()
|
||||
@NotAnonymous()
|
||||
def my_account_auth_tokens(self):
|
||||
|
|
|
|||
|
|
@ -183,6 +183,14 @@ class RhodeCodeAuthPlugin(RhodeCodeAuthPluginBase):
|
|||
|
||||
|
||||
class RhodeCodeSettingsSchema(AuthnPluginSettingsSchemaBase):
|
||||
global_2fa = colander.SchemaNode(
|
||||
colander.Bool(),
|
||||
default=False,
|
||||
description=_('Force all users to use two factor authentication by enabling this.'),
|
||||
missing=False,
|
||||
title=_('Global 2FA'),
|
||||
widget='bool',
|
||||
)
|
||||
|
||||
auth_restriction_choices = [
|
||||
(RhodeCodeAuthPlugin.AUTH_RESTRICTION_NONE, 'All users'),
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import functools
|
|||
import traceback
|
||||
import collections
|
||||
|
||||
import pyotp
|
||||
from sqlalchemy import (
|
||||
or_, and_, not_, func, cast, TypeDecorator, event, select,
|
||||
true, false, null, union_all,
|
||||
|
|
@ -51,6 +52,7 @@ from zope.cachedescriptors.property import Lazy as LazyProperty
|
|||
from pyramid.threadlocal import get_current_request
|
||||
from webhelpers2.text import remove_formatting
|
||||
|
||||
from rhodecode import ConfigGet
|
||||
from rhodecode.lib.str_utils import safe_bytes
|
||||
from rhodecode.translation import _
|
||||
from rhodecode.lib.vcs import get_vcs_instance, VCSError
|
||||
|
|
@ -586,6 +588,7 @@ class User(Base, BaseModel):
|
|||
DEFAULT_USER = 'default'
|
||||
DEFAULT_USER_EMAIL = 'anonymous@rhodecode.org'
|
||||
DEFAULT_GRAVATAR_URL = 'https://secure.gravatar.com/avatar/{md5email}?d=identicon&s={size}'
|
||||
RECOVERY_CODES_COUNT = 10
|
||||
|
||||
user_id = Column("user_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
|
||||
username = Column("username", String(255), nullable=True, unique=None, default=None)
|
||||
|
|
@ -793,6 +796,94 @@ class User(Base, BaseModel):
|
|||
Session.commit()
|
||||
return artifact_token.api_key
|
||||
|
||||
@hybrid_property
|
||||
def secret_2fa(self):
|
||||
if not self.user_data.get('secret_2fa'):
|
||||
secret = pyotp.random_base32()
|
||||
self.update_userdata(secret_2fa=safe_str(enc_utils.encrypt_value(secret, enc_key=ENCRYPTION_KEY)))
|
||||
return secret
|
||||
return safe_str(
|
||||
enc_utils.decrypt_value(self.user_data['secret_2fa'],
|
||||
enc_key=ENCRYPTION_KEY,
|
||||
strict_mode=ConfigGet().get_bool('rhodecode.encrypted_values.strict',
|
||||
missing=True)
|
||||
)
|
||||
)
|
||||
|
||||
def is_totp_valid(self, received_code):
|
||||
totp = pyotp.TOTP(self.secret_2fa)
|
||||
return totp.verify(received_code)
|
||||
|
||||
def is_2fa_recovery_code_valid(self, received_code):
|
||||
encrypted_recovery_codes = self.user_data.get('recovery_codes_2fa', [])
|
||||
recovery_codes = list(map(
|
||||
lambda x: safe_str(
|
||||
enc_utils.decrypt_value(
|
||||
x,
|
||||
enc_key=ENCRYPTION_KEY,
|
||||
strict_mode=ConfigGet().get_bool('rhodecode.encrypted_values.strict', missing=True)
|
||||
)),
|
||||
encrypted_recovery_codes))
|
||||
if received_code in recovery_codes:
|
||||
encrypted_recovery_codes.pop(recovery_codes.index(received_code))
|
||||
self.update_userdata(recovery_codes_2fa=encrypted_recovery_codes)
|
||||
return True
|
||||
return False
|
||||
|
||||
@hybrid_property
|
||||
def has_forced_2fa(self):
|
||||
"""
|
||||
Checks if 2fa was forced for ALL users (including current one)
|
||||
"""
|
||||
from rhodecode.model.settings import SettingsModel
|
||||
# So now we're supporting only auth_rhodecode_global_2f
|
||||
if value := SettingsModel().get_setting_by_name('auth_rhodecode_global_2fa'):
|
||||
return value.app_settings_value
|
||||
return False
|
||||
|
||||
@hybrid_property
|
||||
def has_enabled_2fa(self):
|
||||
"""
|
||||
Checks if 2fa was enabled by user
|
||||
"""
|
||||
if value := self.has_forced_2fa:
|
||||
return value
|
||||
return self.user_data.get('enabled_2fa', False)
|
||||
|
||||
@has_enabled_2fa.setter
|
||||
def has_enabled_2fa(self, val):
|
||||
val = str2bool(val)
|
||||
self.update_userdata(enabled_2fa=str2bool(val))
|
||||
if not val:
|
||||
self.update_userdata(secret_2fa=None, recovery_codes_2fa=[])
|
||||
Session().commit()
|
||||
|
||||
def get_2fa_recovery_codes(self):
|
||||
"""
|
||||
Creates 2fa recovery codes
|
||||
"""
|
||||
recovery_codes = self.user_data.get('recovery_codes_2fa', [])
|
||||
encrypted_codes = []
|
||||
if not recovery_codes:
|
||||
for _ in range(self.RECOVERY_CODES_COUNT):
|
||||
recovery_code = pyotp.random_base32()
|
||||
recovery_codes.append(recovery_code)
|
||||
encrypted_codes.append(safe_str(enc_utils.encrypt_value(recovery_code, enc_key=ENCRYPTION_KEY)))
|
||||
self.update_userdata(recovery_codes_2fa=encrypted_codes)
|
||||
return recovery_codes
|
||||
# User should not check the same recovery codes more than once
|
||||
return []
|
||||
|
||||
def regenerate_2fa_recovery_codes(self):
|
||||
"""
|
||||
Regenerates 2fa recovery codes upon request
|
||||
"""
|
||||
self.update_userdata(recovery_codes_2fa=[])
|
||||
Session().flush()
|
||||
new_recovery_codes = self.get_2fa_recovery_codes()
|
||||
Session().commit()
|
||||
return new_recovery_codes
|
||||
|
||||
@classmethod
|
||||
def get(cls, user_id, cache=False):
|
||||
if not user_id:
|
||||
|
|
|
|||
|
|
@ -104,6 +104,28 @@ def LoginForm(localizer):
|
|||
return _LoginForm
|
||||
|
||||
|
||||
def TOTPForm(localizer, user, allow_recovery_code_use=False):
|
||||
_ = localizer
|
||||
|
||||
class _TOTPForm(formencode.Schema):
|
||||
allow_extra_fields = True
|
||||
filter_extra_fields = False
|
||||
totp = v.Regex(r'^(?:\d{6}|[A-Z0-9]{32})$')
|
||||
|
||||
def to_python(self, value, state=None):
|
||||
validation_checks = [user.is_totp_valid]
|
||||
if allow_recovery_code_use:
|
||||
validation_checks.append(user.is_2fa_recovery_code_valid)
|
||||
form_data = super().to_python(value, state)
|
||||
received_code = form_data['totp']
|
||||
if not any(map(lambda x: x(received_code), validation_checks)):
|
||||
error_msg = _('Code is invalid. Try again!')
|
||||
raise formencode.Invalid(error_msg, v, state, error_dict={'totp': error_msg})
|
||||
return True
|
||||
|
||||
return _TOTPForm
|
||||
|
||||
|
||||
def UserForm(localizer, edit=False, available_languages=None, old_data=None):
|
||||
old_data = old_data or {}
|
||||
available_languages = available_languages or []
|
||||
|
|
|
|||
|
|
@ -224,6 +224,9 @@ function registerRCRoutes() {
|
|||
pyroutes.register('my_account_notifications', '/_admin/my_account/notifications', []);
|
||||
pyroutes.register('my_account_notifications_test_channelstream', '/_admin/my_account/test_channelstream', []);
|
||||
pyroutes.register('my_account_notifications_toggle_visibility', '/_admin/my_account/toggle_visibility', []);
|
||||
pyroutes.register('check_2fa', '/_admin/check_2fa', []);
|
||||
pyroutes.register('my_account_configure_2fa', '/_admin/my_account/configure_2fa', []);
|
||||
pyroutes.register('my_account_regenerate_2fa_recovery_codes', '/_admin/my_account/regenerate_recovery_codes', []);
|
||||
pyroutes.register('my_account_password', '/_admin/my_account/password', []);
|
||||
pyroutes.register('my_account_password_update', '/_admin/my_account/password/update', []);
|
||||
pyroutes.register('my_account_perms', '/_admin/my_account/perms', []);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
<li class="${h.is_active(['profile', 'profile_edit'], c.active)}"><a href="${h.route_path('my_account_profile')}">${_('Profile')}</a></li>
|
||||
<li class="${h.is_active('emails', c.active)}"><a href="${h.route_path('my_account_emails')}">${_('Emails')}</a></li>
|
||||
<li class="${h.is_active('password', c.active)}"><a href="${h.route_path('my_account_password')}">${_('Password')}</a></li>
|
||||
<li class="${h.is_active('2FA', c.active)}"><a href="${h.route_path('my_account_enable_2fa')}">${_('2FA')}</a></li>
|
||||
<li class="${h.is_active('bookmarks', c.active)}"><a href="${h.route_path('my_account_bookmarks')}">${_('Bookmarks')}</a></li>
|
||||
<li class="${h.is_active('auth_tokens', c.active)}"><a href="${h.route_path('my_account_auth_tokens')}">${_('Auth Tokens')}</a></li>
|
||||
<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>
|
||||
|
|
|
|||
140
rhodecode/templates/admin/my_account/my_account_2fa.mako
Normal file
140
rhodecode/templates/admin/my_account/my_account_2fa.mako
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<%namespace name="base" file="/base/base.mako"/>
|
||||
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">${_('Enable/Disable 2FA for your account')}</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="form">
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<div class="label">
|
||||
<label>${_('2FA status')}:</label>
|
||||
</div>
|
||||
<div class="checkboxes">
|
||||
|
||||
<div class="form-check">
|
||||
<label class="form-check-label">
|
||||
<input type="radio" id="2faEnabled" value="1" ${'checked' if c.state_of_2fa else ''}>
|
||||
${_('Enabled')}
|
||||
</label>
|
||||
<label class="form-check-label">
|
||||
<input type="radio" id="2faDisabled" value="0" ${'checked' if not c.state_of_2fa else ''}>
|
||||
${_('Disabled')}
|
||||
</label>
|
||||
</div>
|
||||
% if c.locked_2fa:
|
||||
<span class="help-block">${_('2FA settings cannot be changed here, because 2FA was forced enabled by RhodeCode Administrator.')}</span>
|
||||
% endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="saveBtn" class="btn btn-primary" ${'disabled' if c.locked_2fa else ''}>${_('Save')}</button>
|
||||
</div>
|
||||
<div id="codesPopup" class="modal">
|
||||
<div class="modal-content">
|
||||
<ul id="recoveryCodesList"></ul>
|
||||
<button id="copyAllBtn" class="btn btn-primary">Copy All</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
% if c.state_of_2fa:
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">${_('Regenerate 2FA recovery codes for your account')}</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form id="2faForm">
|
||||
<input type="text" name="totp" placeholder="${_('Verify the code from the app')}" pattern="\d{6}"
|
||||
style="width: 20%">
|
||||
<button type="button" class="btn btn-primary" onclick="submitForm()">Verify</button>
|
||||
</form>
|
||||
<div id="result"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
% endif
|
||||
<script>
|
||||
function submitForm() {
|
||||
let formData = new FormData(document.getElementById("2faForm"));
|
||||
let xhr = new XMLHttpRequest();
|
||||
let success = function (response) {
|
||||
let recovery_codes = response.recovery_codes;
|
||||
const codesList = document.getElementById("recoveryCodesList");
|
||||
|
||||
codesList.innerHTML = "";
|
||||
recovery_codes.forEach(code => {
|
||||
const listItem = document.createElement("li");
|
||||
listItem.textContent = code;
|
||||
codesList.appendChild(listItem);
|
||||
});
|
||||
}
|
||||
xhr.onreadystatechange = function () {
|
||||
if (xhr.readyState == 4 && xhr.status == 200) {
|
||||
let responseDoc = new DOMParser().parseFromString(xhr.responseText, "text/html");
|
||||
let contentToDisplay = responseDoc.querySelector('#formErrors');
|
||||
if (contentToDisplay) {
|
||||
document.getElementById("result").innerHTML = contentToDisplay.innerHTML;
|
||||
} else {
|
||||
let regenerate_url = pyroutes.url('my_account_regenerate_2fa_recovery_codes');
|
||||
ajaxPOST(regenerate_url, {'csrf_token': CSRF_TOKEN}, success);
|
||||
showRecoveryCodesPopup();
|
||||
}
|
||||
}
|
||||
};
|
||||
let url = pyroutes.url('check_2fa');
|
||||
xhr.open("POST", url, true);
|
||||
xhr.send(formData);
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
document.getElementById('2faEnabled').addEventListener('click', function () {
|
||||
document.getElementById('2faDisabled').checked = false;
|
||||
});
|
||||
document.getElementById('2faDisabled').addEventListener('click', function () {
|
||||
document.getElementById('2faEnabled').checked = false;
|
||||
});
|
||||
|
||||
function getStateValue() {
|
||||
if (document.getElementById('2faEnabled').checked) {
|
||||
return '1';
|
||||
} else {
|
||||
return '0';
|
||||
}
|
||||
};
|
||||
|
||||
function saveChanges(state) {
|
||||
|
||||
let post_data = {'state': state, 'csrf_token': CSRF_TOKEN};
|
||||
let url = pyroutes.url('my_account_configure_2fa');
|
||||
|
||||
ajaxPOST(url, post_data, null)
|
||||
};
|
||||
document.getElementById('saveBtn').addEventListener('click', function () {
|
||||
var state = getStateValue();
|
||||
saveChanges(state);
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
function showRecoveryCodesPopup() {
|
||||
const popup = document.getElementById("codesPopup");
|
||||
popup.style.display = "block";
|
||||
}
|
||||
|
||||
document.getElementById("copyAllBtn").addEventListener("click", function () {
|
||||
const codesListItems = document.querySelectorAll("#recoveryCodesList li");
|
||||
const allCodes = Array.from(codesListItems).map(item => item.textContent).join(", ");
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = allCodes;
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
|
||||
document.body.removeChild(textarea);
|
||||
const popup = document.getElementById("codesPopup");
|
||||
popup.style.display = ""
|
||||
});
|
||||
</script>
|
||||
153
rhodecode/templates/configure_2fa.mako
Normal file
153
rhodecode/templates/configure_2fa.mako
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<%inherit file="base/root.mako"/>
|
||||
|
||||
<%def name="title()">
|
||||
${_('Setup authenticator app')}
|
||||
%if c.rhodecode_name:
|
||||
· ${h.branding(c.rhodecode_name)}
|
||||
%endif
|
||||
</%def>
|
||||
<style>body{background-color:#eeeeee;}</style>
|
||||
|
||||
<div class="loginbox">
|
||||
<div class="header-account">
|
||||
<div id="header-inner" class="title">
|
||||
<div id="logo">
|
||||
% if c.rhodecode_name:
|
||||
<div class="branding">
|
||||
<a href="${h.route_path('home')}">${h.branding(c.rhodecode_name)}</a>
|
||||
</div>
|
||||
% endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="loginwrapper">
|
||||
<h1>Setup the authenticator app</h1>
|
||||
<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>
|
||||
<rhodecode-toast id="notifications"></rhodecode-toast>
|
||||
|
||||
<div id="setup_2fa">
|
||||
<div class="sign-in-title">
|
||||
<h1>${_('Scan the QR code')}</h1>
|
||||
</div>
|
||||
<p>Use an authenticator app to scan.</p>
|
||||
<img src="data:image/png;base64, ${qr}"/>
|
||||
<p>${_('Unable to scan?')} <a id="toggleLink">${_('Click here')}</a></p>
|
||||
<div id="secretDiv" class="hidden">
|
||||
<p>${_('Copy and use this code to manually setup an authenticator app')}</p>
|
||||
<input type="text" id="secretField" value=${key}>
|
||||
<i class="tooltip icon-clipboard clipboard-action" data-clipboard-text="" title="${_('Copy the secret key')}"></i>
|
||||
</div>
|
||||
<div id="codesPopup" class="modal">
|
||||
<div class="modal-content">
|
||||
<ul id="recoveryCodesList"></ul>
|
||||
<button id="copyAllBtn" class="btn btn-primary">Copy All</button>
|
||||
</div>
|
||||
</div>
|
||||
<br><br>
|
||||
<div id="verify_2fa">
|
||||
${h.secure_form(h.route_path('setup_2fa'), request=request, id='totp_form')}
|
||||
<div class="form mt-4">
|
||||
<div class="field">
|
||||
<p>
|
||||
<div class="label">
|
||||
<label for="totp" class="form-label text-dark font-weight-bold" style="text-align: left;">${_('Verify the code from the app')}:</label>
|
||||
</div>
|
||||
</p>
|
||||
<p>
|
||||
<div>
|
||||
<div class="input-group">
|
||||
${h.text('totp', class_='form-control', style='width: 40%;')}
|
||||
<div id="formErrors">
|
||||
%if 'totp' in errors:
|
||||
<span class="error-message">${errors.get('totp')}</span>
|
||||
<br />
|
||||
%endif
|
||||
</div>
|
||||
<div class="input-group-append">
|
||||
${h.submit('save',_('Verify'),class_="btn btn-primary", style='width: 40%;', disabled=not codes_viewed)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let clipboardIcons = document.querySelectorAll('.clipboard-action');
|
||||
|
||||
clipboardIcons.forEach(function(icon) {
|
||||
icon.addEventListener('click', function() {
|
||||
var inputField = document.getElementById('secretField');
|
||||
inputField.select();
|
||||
document.execCommand('copy');
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
document.getElementById('toggleLink').addEventListener('click', function() {
|
||||
let hiddenField = document.getElementById('secretDiv');
|
||||
if (hiddenField.classList.contains('hidden')) {
|
||||
hiddenField.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
const recovery_codes_string = '${recovery_codes}';
|
||||
const cleaned_recovery_codes_string = recovery_codes_string
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const recovery_codes = JSON.parse(cleaned_recovery_codes_string);
|
||||
|
||||
const cleaned_recovery_codes = recovery_codes.map(code => code.replace(/['"]/g, ''));
|
||||
|
||||
function showRecoveryCodesPopup() {
|
||||
const popup = document.getElementById("codesPopup");
|
||||
const codesList = document.getElementById("recoveryCodesList");
|
||||
const verify_btn = document.getElementById('save')
|
||||
|
||||
if (verify_btn.disabled) {
|
||||
codesList.innerHTML = "";
|
||||
|
||||
cleaned_recovery_codes.forEach(code => {
|
||||
const listItem = document.createElement("li");
|
||||
listItem.textContent = code;
|
||||
codesList.appendChild(listItem);
|
||||
});
|
||||
|
||||
popup.style.display = "block";
|
||||
verify_btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("save").addEventListener("mouseover", showRecoveryCodesPopup);
|
||||
|
||||
const popup = document.getElementById("codesPopup");
|
||||
const closeButton = document.querySelector(".close");
|
||||
window.onclick = function(event) {
|
||||
if (event.target === popup || event.target === closeButton) {
|
||||
popup.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("copyAllBtn").addEventListener("click", function() {
|
||||
const codesListItems = document.querySelectorAll("#recoveryCodesList li");
|
||||
const allCodes = Array.from(codesListItems).map(item => item.textContent).join(", ");
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = allCodes;
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
|
||||
document.body.removeChild(textarea);
|
||||
});
|
||||
</script>
|
||||
37
rhodecode/templates/verify_2fa.mako
Normal file
37
rhodecode/templates/verify_2fa.mako
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<%inherit file="/base/root.mako"/>
|
||||
<%def name="title()">
|
||||
${_('Check 2FA')}
|
||||
%if c.rhodecode_name:
|
||||
· ${h.branding(c.rhodecode_name)}
|
||||
%endif
|
||||
</%def>
|
||||
|
||||
<div class="box">
|
||||
<div class="verify2FA">
|
||||
${h.secure_form(h.route_path('check_2fa'), request=request, id='totp_form')}
|
||||
<div class="form mt-4" style="position: relative; margin-left: 35%; margin-top: 20%;">
|
||||
<div class="field">
|
||||
<p>
|
||||
<div class="label">
|
||||
<label for="totp" class="form-label text-dark font-weight-bold" style="text-align: left;">${_('Verify the code from the app')}:</label>
|
||||
</div>
|
||||
</p>
|
||||
<p>
|
||||
<div>
|
||||
<div class="input-group">
|
||||
${h.text('totp', class_="form-control", style='width: 38%;')}
|
||||
<div id="formErrors">
|
||||
%if 'totp' in errors:
|
||||
<span class="error-message">${errors.get('totp')}</span>
|
||||
<br />
|
||||
%endif
|
||||
</div>
|
||||
<br />
|
||||
${h.submit('save',_('Verify'),class_="btn btn-primary", style='width: 40%;')}
|
||||
</div>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -106,6 +106,7 @@ def get_url_defs():
|
|||
+ "/gists/{gist_id}/rev/{revision}/{format}/{f_path}",
|
||||
"login": ADMIN_PREFIX + "/login",
|
||||
"logout": ADMIN_PREFIX + "/logout",
|
||||
"check_2fa": ADMIN_PREFIX + "/check_2fa",
|
||||
"register": ADMIN_PREFIX + "/register",
|
||||
"reset_password": ADMIN_PREFIX + "/password_reset",
|
||||
"reset_password_confirmation": ADMIN_PREFIX + "/password_reset_confirmation",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue