Prototype: OTP auth, styleguide overhaul, login/session rework
Replace password reset with email OTP verification flow. Add auth_otp module, OTP templates, and email delivery. Expand styleguide CSS with full component library. Rework login, register, and admin views for cookie sessions. Remove legacy 2FA templates and password reset flow. Update SSH wrappers, forms, validators, and middleware.
This commit is contained in:
parent
1dac65fc8a
commit
722c3bd369
38 changed files with 4100 additions and 1835 deletions
|
|
@ -129,9 +129,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"
|
||||
EXTRA_VIEWS_TO_IGNORE = [
|
||||
"login", "register", "logout",
|
||||
"login_otp_verify",
|
||||
"sudo_otp_verify", "resend_otp",
|
||||
]
|
||||
|
||||
def __init__(self, context, request):
|
||||
self.request = request
|
||||
|
|
@ -147,70 +149,6 @@ 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):
|
||||
if view_name in self.DONT_CHECKOUT_VIEWS:
|
||||
return
|
||||
|
||||
log.debug("Checking if user %s needs password change on view %s", user_obj, view_name)
|
||||
|
||||
skip_user_views = [
|
||||
"logout",
|
||||
"login",
|
||||
"check_2fa",
|
||||
"my_account_password",
|
||||
"my_account_password_update",
|
||||
]
|
||||
|
||||
if not user_obj:
|
||||
return
|
||||
|
||||
if user_obj.username in [User.DEFAULT_USER, User.GHOST_USER, User.AI_USER, User.SECURITY_USER]:
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
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)
|
||||
h.flash(
|
||||
"You are required to change your password",
|
||||
"warning",
|
||||
ignore_duplicate=True,
|
||||
)
|
||||
|
||||
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.needs_2fa_configure and view_name != self.SETUP_2FA_VIEW:
|
||||
h.flash(
|
||||
"You are required to configure 2FA",
|
||||
"warning",
|
||||
ignore_duplicate=False,
|
||||
)
|
||||
# Special case for users created "on the fly" (ldap case for new user)
|
||||
user_obj.check_2fa_required = 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 user_obj.check_2fa_required 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
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ from rhodecode.apps._base import BaseAppView, DataGridAppView
|
|||
from rhodecode.lib.auth import LoginRequired, CSRFRequired, NotAnonymous, HasPermissionAny, HasRepoGroupPermissionAny
|
||||
from rhodecode.lib import helpers as h, audit_logger
|
||||
from rhodecode.lib.str_utils import safe_int, safe_str
|
||||
from rhodecode.lib.utils import repo_name_slug
|
||||
from rhodecode.model.forms import RepoGroupForm
|
||||
from rhodecode.model.permission import PermissionModel
|
||||
from rhodecode.model.repo_group import RepoGroupModel
|
||||
|
|
@ -283,18 +284,23 @@ class AdminRepoGroupsView(BaseAppView, DataGridAppView):
|
|||
can_create = self._can_create_repo_group(parent_group_id)
|
||||
|
||||
self._load_form_data(c)
|
||||
# permissions for can create group based on parent_id are checked
|
||||
# here in the Form
|
||||
|
||||
raw_group_name = self.request.POST.get("group_name", "").strip()
|
||||
|
||||
# Path-based creation: if the name contains "/" we create
|
||||
# intermediate groups automatically (e.g. "org/team/project")
|
||||
if "/" in raw_group_name:
|
||||
return self._create_repo_group_path(c, raw_group_name, parent_group_id)
|
||||
|
||||
# Standard single-group creation via formencode
|
||||
available_groups = list(map(lambda k: safe_str(k[0]), c.repo_groups))
|
||||
repo_group_form = RepoGroupForm(
|
||||
self.request.translate, available_groups=available_groups, can_create_in_root=can_create
|
||||
)()
|
||||
|
||||
repo_group_name = self.request.POST.get("group_name")
|
||||
try:
|
||||
owner = self._rhodecode_user
|
||||
form_result = repo_group_form.to_python(dict(self.request.POST))
|
||||
copy_permissions = form_result.get("group_copy_permissions")
|
||||
repo_group = RepoGroupModel().create(
|
||||
group_name=form_result["group_name_full"],
|
||||
group_description=form_result["group_description"],
|
||||
|
|
@ -332,9 +338,97 @@ class AdminRepoGroupsView(BaseAppView, DataGridAppView):
|
|||
return Response(html)
|
||||
except Exception:
|
||||
log.exception("Exception during creation of repository group")
|
||||
h.flash(_("Error occurred during creation of repository group %s") % repo_group_name, category="error")
|
||||
h.flash(_("Error occurred during creation of repository group %s") % raw_group_name, category="error")
|
||||
raise HTTPFound(h.route_path("home"))
|
||||
|
||||
PermissionModel().trigger_permission_flush()
|
||||
|
||||
raise HTTPFound(h.route_path("repo_group_home", repo_group_name=form_result["group_name_full"]))
|
||||
|
||||
def _create_repo_group_path(self, c, path_input, parent_group_id):
|
||||
"""
|
||||
Create a chain of nested repo groups from a slash-separated path.
|
||||
E.g. "org/team/project" creates org, org/team, org/team/project
|
||||
(skipping any that already exist).
|
||||
"""
|
||||
_ = self.request.translate
|
||||
owner = self._rhodecode_user
|
||||
|
||||
# Resolve parent group prefix
|
||||
parent_prefix = ""
|
||||
if parent_group_id and parent_group_id != -1:
|
||||
parent_group = RepoGroup.get(parent_group_id)
|
||||
if parent_group:
|
||||
parent_prefix = parent_group.group_name + "/"
|
||||
|
||||
# Split path into segments and slugify each one
|
||||
segments = [repo_name_slug(s.strip()) for s in path_input.split("/") if s.strip()]
|
||||
if not segments:
|
||||
h.flash(_("Invalid group path"), category="error")
|
||||
raise HTTPFound(h.route_path("repo_group_new"))
|
||||
|
||||
copy_permissions = self.request.POST.get("group_copy_permissions") == "True"
|
||||
description = self.request.POST.get("group_description", "")
|
||||
created_groups = []
|
||||
final_group_name = None
|
||||
|
||||
try:
|
||||
for i, segment in enumerate(segments):
|
||||
full_name = parent_prefix + "/".join(segments[: i + 1])
|
||||
|
||||
# Skip if group already exists
|
||||
existing = RepoGroup.get_by_group_name(full_name)
|
||||
if existing:
|
||||
log.debug("Group %s already exists, skipping", full_name)
|
||||
continue
|
||||
|
||||
# Only apply description to the final group
|
||||
grp_description = description if i == len(segments) - 1 else segment
|
||||
|
||||
repo_group = RepoGroupModel().create(
|
||||
group_name=full_name,
|
||||
group_description=grp_description,
|
||||
owner=owner.user_id,
|
||||
copy_permissions=copy_permissions,
|
||||
)
|
||||
Session().flush()
|
||||
|
||||
audit_logger.store_web(
|
||||
"repo_group.create",
|
||||
action_data={"data": repo_group.get_api_data()},
|
||||
user=self._rhodecode_user,
|
||||
)
|
||||
created_groups.append(full_name)
|
||||
final_group_name = full_name
|
||||
|
||||
Session().commit()
|
||||
|
||||
if not final_group_name:
|
||||
# All groups already existed; redirect to the deepest one
|
||||
final_group_name = parent_prefix + "/".join(segments)
|
||||
|
||||
if len(created_groups) == 1:
|
||||
msg = _("Created repository group %s") % h.link_to(
|
||||
created_groups[0],
|
||||
h.route_path("repo_group_home", repo_group_name=created_groups[0]),
|
||||
)
|
||||
elif created_groups:
|
||||
msg = _("Created %d repository groups: %s") % (
|
||||
len(created_groups),
|
||||
", ".join(created_groups),
|
||||
)
|
||||
else:
|
||||
msg = _("All groups already exist")
|
||||
|
||||
h.flash(h.literal(msg), category="success")
|
||||
|
||||
except Exception:
|
||||
log.exception("Exception during path-based repository group creation")
|
||||
h.flash(
|
||||
_("Error creating repository group path: %s") % path_input,
|
||||
category="error",
|
||||
)
|
||||
raise HTTPFound(h.route_path("home"))
|
||||
|
||||
PermissionModel().trigger_permission_flush()
|
||||
raise HTTPFound(h.route_path("repo_group_home", repo_group_name=final_group_name))
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ from rhodecode.lib.exceptions import (
|
|||
)
|
||||
from rhodecode.lib import ext_json
|
||||
from rhodecode.lib.auth import LoginRequired, HasPermissionAllDecorator, CSRFRequired
|
||||
from rhodecode.lib.auth_otp import SudoOTPRequired
|
||||
from rhodecode.lib import helpers as h
|
||||
from rhodecode.lib.helpers import SqlPage
|
||||
from rhodecode.lib.utils2 import safe_int, safe_str, AttributeDict
|
||||
|
|
@ -360,6 +361,7 @@ class UsersView(UserAppView):
|
|||
@LoginRequired()
|
||||
@HasPermissionAllDecorator("hg.admin")
|
||||
@CSRFRequired()
|
||||
@SudoOTPRequired
|
||||
def user_delete(self):
|
||||
_ = self.request.translate
|
||||
c = self.load_default_context()
|
||||
|
|
|
|||
|
|
@ -35,60 +35,61 @@ def includeme(config):
|
|||
renderer="rhodecode:templates/login.mako",
|
||||
)
|
||||
|
||||
config.add_route(name="login_otp_verify", pattern=ADMIN_PREFIX + "/login/verify")
|
||||
config.add_view(
|
||||
LoginView,
|
||||
attr="login_otp_verify",
|
||||
route_name="login_otp_verify",
|
||||
request_method="GET",
|
||||
renderer="rhodecode:templates/otp_verify.mako",
|
||||
)
|
||||
config.add_view(
|
||||
LoginView,
|
||||
attr="login_otp_verify_post",
|
||||
route_name="login_otp_verify",
|
||||
request_method="POST",
|
||||
renderer="rhodecode:templates/otp_verify.mako",
|
||||
)
|
||||
|
||||
config.add_route(name="resend_otp", pattern=ADMIN_PREFIX + "/otp/resend")
|
||||
config.add_view(
|
||||
LoginView,
|
||||
attr="resend_otp",
|
||||
route_name="resend_otp",
|
||||
request_method="POST",
|
||||
renderer="json",
|
||||
)
|
||||
|
||||
config.add_route(name="logout", pattern=ADMIN_PREFIX + "/logout")
|
||||
config.add_view(LoginView, attr="logout", route_name="logout", request_method="POST")
|
||||
|
||||
# Register redirects to login (unified flow)
|
||||
config.add_route(name="register", pattern=ADMIN_PREFIX + "/register")
|
||||
config.add_view(
|
||||
LoginView,
|
||||
attr="register",
|
||||
route_name="register",
|
||||
request_method="GET",
|
||||
renderer="rhodecode:templates/register.mako",
|
||||
)
|
||||
config.add_view(
|
||||
LoginView,
|
||||
attr="register_post",
|
||||
attr="register",
|
||||
route_name="register",
|
||||
request_method="POST",
|
||||
renderer="rhodecode:templates/register.mako",
|
||||
)
|
||||
|
||||
config.add_route(name="reset_password", pattern=ADMIN_PREFIX + "/password_reset")
|
||||
config.add_route(name="sudo_otp_verify", pattern=ADMIN_PREFIX + "/sudo/verify")
|
||||
config.add_view(
|
||||
LoginView,
|
||||
attr="password_reset",
|
||||
route_name="reset_password",
|
||||
request_method=("GET", "POST"),
|
||||
renderer="rhodecode:templates/password_reset.mako",
|
||||
)
|
||||
|
||||
config.add_route(name="reset_password_confirmation", pattern=ADMIN_PREFIX + "/password_reset_confirmation")
|
||||
config.add_view(
|
||||
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",
|
||||
attr="sudo_otp_verify",
|
||||
route_name="sudo_otp_verify",
|
||||
request_method="GET",
|
||||
renderer="rhodecode:templates/verify_2fa.mako",
|
||||
renderer="rhodecode:templates/sudo_otp_verify.mako",
|
||||
)
|
||||
config.add_view(
|
||||
LoginView,
|
||||
attr="verify_2fa",
|
||||
route_name="check_2fa",
|
||||
attr="sudo_otp_verify_post",
|
||||
route_name="sudo_otp_verify",
|
||||
request_method="POST",
|
||||
renderer="rhodecode:templates/verify_2fa.mako",
|
||||
renderer="rhodecode:templates/sudo_otp_verify.mako",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,9 +16,10 @@
|
|||
# RhodeCode Enterprise Edition, including its added features, Support services,
|
||||
# and proprietary license terms, please see https://rhodecode.com/licenses/
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
import pyotp
|
||||
import qrcode
|
||||
import dataclasses
|
||||
import datetime
|
||||
import formencode
|
||||
|
|
@ -26,17 +27,12 @@ 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
|
||||
|
||||
import rhodecode
|
||||
from rhodecode.apps._base import BaseAppView
|
||||
from rhodecode.authentication.base import authenticate, HTTP_TYPE
|
||||
from rhodecode.authentication.plugins import auth_rhodecode
|
||||
from rhodecode.events import UserRegistered, trigger
|
||||
from rhodecode.lib import helpers as h
|
||||
from rhodecode.lib import audit_logger
|
||||
|
|
@ -44,10 +40,9 @@ from rhodecode.lib.auth import AuthUser, HasPermissionAnyDecorator, CSRFRequired
|
|||
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, TOTPForm
|
||||
from rhodecode.model.db import User
|
||||
from rhodecode.model.forms import LoginForm, OTPVerifyForm
|
||||
from rhodecode.model.meta import Session
|
||||
from rhodecode.model.auth_token import AuthTokenModel
|
||||
from rhodecode.model.settings import SettingsModel
|
||||
from rhodecode.model.user import UserModel
|
||||
from rhodecode.translation import _
|
||||
|
|
@ -84,15 +79,8 @@ def store_user_in_session(session, user_identifier, remember=False):
|
|||
safe_cs["password"] = "****"
|
||||
log.info("user %s is now authenticated and stored in session, session attrs %s", user_identifier, safe_cs)
|
||||
|
||||
# dumps session attrs back to cookie
|
||||
if hasattr(session, '_update_cookie_out'):
|
||||
session._update_cookie_out()
|
||||
# we set new cookie
|
||||
headers = None
|
||||
if session.request["set_cookie"]:
|
||||
# send set-cookie headers back to response to update cookie
|
||||
headers = [("Set-Cookie", session.request["cookie_out"])]
|
||||
return headers
|
||||
# Cookie is set automatically by Pyramid's session response callback
|
||||
return None
|
||||
|
||||
|
||||
def get_came_from(request):
|
||||
|
|
@ -114,6 +102,21 @@ def get_came_from(request):
|
|||
return came_from or default_came_from
|
||||
|
||||
|
||||
def _derive_username_from_email(email):
|
||||
local_part = email.split("@")[0]
|
||||
# sanitize: keep only alphanumeric, dash, underscore, dot
|
||||
username = re.sub(r'[^a-zA-Z0-9._-]', '_', local_part).lower()
|
||||
if not username:
|
||||
username = 'user'
|
||||
# ensure unique
|
||||
base = username
|
||||
suffix = 0
|
||||
while User.get_by_username(username):
|
||||
suffix += 1
|
||||
username = f'{base}_{suffix}'
|
||||
return username
|
||||
|
||||
|
||||
class LoginView(BaseAppView):
|
||||
def load_default_context(self):
|
||||
c = self._get_local_tmpl_context()
|
||||
|
|
@ -144,6 +147,28 @@ class LoginView(BaseAppView):
|
|||
|
||||
return captcha_status, captcha_message
|
||||
|
||||
def _send_otp_email(self, email, code, purpose):
|
||||
from rhodecode.lib.celerylib import tasks, run_task
|
||||
from rhodecode.model.notification import EmailNotificationModel
|
||||
|
||||
import rhodecode
|
||||
smtp_server = rhodecode.CONFIG.get('smtp_server')
|
||||
log_code = code if not smtp_server else 'X' * len(code)
|
||||
log.info('[OTP] code=%s email=%s purpose=%s', log_code, email, purpose)
|
||||
|
||||
email_kwargs = {
|
||||
'otp_code': code,
|
||||
'purpose': purpose,
|
||||
}
|
||||
|
||||
(subject, email_body, email_body_plaintext) = EmailNotificationModel().render_email(
|
||||
EmailNotificationModel.TYPE_OTP_CODE, **email_kwargs
|
||||
)
|
||||
|
||||
run_task(tasks.send_email, [email], subject, email_body_plaintext, email_body)
|
||||
|
||||
# ---- Login Step 1: Email ----
|
||||
|
||||
def login(self):
|
||||
c = self.load_default_context()
|
||||
auth_user = self._rhodecode_user
|
||||
|
|
@ -171,46 +196,255 @@ class LoginView(BaseAppView):
|
|||
login_form = LoginForm(self.request.translate)()
|
||||
|
||||
try:
|
||||
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.check_2fa_required = True
|
||||
email = form_result["email"]
|
||||
remember = form_result["remember"]
|
||||
|
||||
headers = store_user_in_session(self.session, user_identifier=username, remember=form_result["remember"])
|
||||
log.debug('Redirecting to "%s" after login.', c.came_from)
|
||||
# Throttle to prevent enumeration
|
||||
time.sleep(1)
|
||||
|
||||
audit_user = audit_logger.UserWrap(
|
||||
username=self.request.POST.get("username"), ip_addr=self.request.remote_addr
|
||||
)
|
||||
action_data = {"user_agent": self.request.user_agent}
|
||||
audit_logger.store_web("user.login.success", action_data=action_data, user=audit_user, commit=True)
|
||||
user = User.get_by_email(email)
|
||||
if user and user.active:
|
||||
# Existing active user — generate OTP on user object
|
||||
code = user.generate_otp("login")
|
||||
Session().commit()
|
||||
self._send_otp_email(email, code, "login")
|
||||
self.session["otp_is_new_user"] = False
|
||||
elif user and not user.active:
|
||||
# Inactive user — don't send OTP but still redirect (anti-enumeration)
|
||||
log.debug("login OTP request for inactive email %s", email)
|
||||
self.session["otp_is_new_user"] = False
|
||||
else:
|
||||
# Unknown email — auto-register if allowed
|
||||
default_user = User.get_default_user()
|
||||
perms = default_user.AuthUser().permissions["global"]
|
||||
registration_allowed = (
|
||||
"hg.register.auto_activate" in perms
|
||||
or "hg.register.manual_activate" in perms
|
||||
)
|
||||
|
||||
if registration_allowed:
|
||||
# Generate session-based OTP for new user registration
|
||||
code = ''.join(str(secrets.randbelow(10)) for _ in range(6))
|
||||
code_hash = hashlib.sha256(code.encode('utf-8')).hexdigest()
|
||||
self.session["otp_hash"] = code_hash
|
||||
self.session["otp_created_at"] = time.time()
|
||||
self.session["otp_attempts"] = 0
|
||||
self.session["otp_is_new_user"] = True
|
||||
self._send_otp_email(email, code, "login")
|
||||
else:
|
||||
log.debug("login OTP request for unknown email %s, registration disabled", email)
|
||||
self.session["otp_is_new_user"] = False
|
||||
|
||||
# Always store in session and redirect (prevent email enumeration)
|
||||
self.session["otp_email"] = email
|
||||
self.session["otp_remember"] = remember
|
||||
self.session["otp_came_from"] = c.came_from
|
||||
if hasattr(self.session, 'save'):
|
||||
self.session.save()
|
||||
|
||||
raise HTTPFound(self.request.route_path("login_otp_verify"))
|
||||
|
||||
raise HTTPFound(c.came_from, headers=headers)
|
||||
except formencode.Invalid as errors:
|
||||
defaults = errors.value
|
||||
# remove password from filling in form again
|
||||
defaults.pop("password", None)
|
||||
render_ctx = {
|
||||
"errors": errors.error_dict,
|
||||
"defaults": defaults,
|
||||
}
|
||||
|
||||
audit_user = audit_logger.UserWrap(
|
||||
username=self.request.POST.get("username"), ip_addr=self.request.remote_addr
|
||||
)
|
||||
action_data = {"user_agent": self.request.user_agent}
|
||||
audit_logger.store_web("user.login.failure", action_data=action_data, user=audit_user, commit=True)
|
||||
return self._get_template_context(c, **render_ctx)
|
||||
|
||||
except UserCreationError as e:
|
||||
# headers auth or other auth functions that create users on
|
||||
# the fly can throw this exception signaling that there's issue
|
||||
# with user creation, explanation should be provided in
|
||||
# Exception itself
|
||||
h.flash(e, category="error")
|
||||
return self._get_template_context(c)
|
||||
# ---- Login Step 2: OTP Verify ----
|
||||
|
||||
def login_otp_verify(self):
|
||||
c = self.load_default_context()
|
||||
email = self.session.get("otp_email")
|
||||
if not email:
|
||||
raise HTTPFound(self.request.route_path("login"))
|
||||
return self._get_template_context(c,
|
||||
verify_route="login_otp_verify",
|
||||
otp_digits=6,
|
||||
purpose="login",
|
||||
otp_email=email,
|
||||
)
|
||||
|
||||
def login_otp_verify_post(self):
|
||||
c = self.load_default_context()
|
||||
email = self.session.get("otp_email")
|
||||
is_new_user = self.session.get("otp_is_new_user", False)
|
||||
log.debug("OTP verify: email=%s is_new_user=%s session_keys=%s",
|
||||
email, is_new_user, list(self.session.keys()))
|
||||
if not email:
|
||||
raise HTTPFound(self.request.route_path("login"))
|
||||
|
||||
otp_form = OTPVerifyForm(self.request.translate, digits=6)()
|
||||
|
||||
try:
|
||||
form_result = otp_form.to_python(self.request.POST)
|
||||
code = form_result["otp"]
|
||||
|
||||
if is_new_user:
|
||||
self._verify_new_user_otp(email, code, form_result)
|
||||
else:
|
||||
self._verify_existing_user_otp(email, code, form_result)
|
||||
|
||||
except formencode.Invalid as errors:
|
||||
defaults = errors.value
|
||||
render_ctx = {
|
||||
"errors": errors.error_dict,
|
||||
"defaults": defaults,
|
||||
"verify_route": "login_otp_verify",
|
||||
"otp_digits": 6,
|
||||
"purpose": "login",
|
||||
"otp_email": email,
|
||||
}
|
||||
return self._get_template_context(c, **render_ctx)
|
||||
|
||||
def _verify_existing_user_otp(self, email, code, form_result):
|
||||
user = User.get_by_email(email)
|
||||
if not user or not user.verify_otp(code, "login"):
|
||||
Session().commit()
|
||||
error_msg = _("Invalid or expired code. Please try again.")
|
||||
raise formencode.Invalid(error_msg, form_result, None,
|
||||
error_dict={"otp": error_msg})
|
||||
|
||||
Session().commit()
|
||||
|
||||
remember = self.session.get("otp_remember", False)
|
||||
came_from = self.session.get("otp_came_from", h.route_path("home"))
|
||||
|
||||
# Clear OTP session data
|
||||
self._clear_otp_session()
|
||||
|
||||
headers = store_user_in_session(self.session, user_identifier=email, remember=remember)
|
||||
|
||||
audit_user = audit_logger.UserWrap(
|
||||
username=user.username, ip_addr=self.request.remote_addr
|
||||
)
|
||||
action_data = {"user_agent": self.request.user_agent}
|
||||
audit_logger.store_web("user.login.success", action_data=action_data, user=audit_user, commit=True)
|
||||
|
||||
raise HTTPFound(came_from, headers=headers)
|
||||
|
||||
def _verify_new_user_otp(self, email, code, form_result):
|
||||
from rhodecode.authentication.plugins import auth_rhodecode
|
||||
|
||||
stored_hash = self.session.get("otp_hash")
|
||||
created_at = self.session.get("otp_created_at", 0)
|
||||
attempts = self.session.get("otp_attempts", 0)
|
||||
|
||||
if attempts >= 5:
|
||||
self._clear_otp_session()
|
||||
h.flash(_("Too many attempts. Please start over."), category="error")
|
||||
raise HTTPFound(self.request.route_path("login"))
|
||||
|
||||
if (time.time() - created_at) > 300:
|
||||
self._clear_otp_session()
|
||||
h.flash(_("Code expired. Please start over."), category="error")
|
||||
raise HTTPFound(self.request.route_path("login"))
|
||||
|
||||
self.session["otp_attempts"] = attempts + 1
|
||||
if hasattr(self.session, 'save'):
|
||||
self.session.save()
|
||||
|
||||
code_hash = hashlib.sha256(code.encode('utf-8')).hexdigest()
|
||||
if code_hash != stored_hash:
|
||||
error_msg = _("Invalid code. Please try again.")
|
||||
raise formencode.Invalid(error_msg, form_result, None,
|
||||
error_dict={"otp": error_msg})
|
||||
|
||||
# OTP verified — create the user account
|
||||
auto_active = "hg.register.auto_activate" in User.get_default_user().AuthUser().permissions["global"]
|
||||
username = _derive_username_from_email(email)
|
||||
|
||||
# First real user becomes admin (bootstrap)
|
||||
system_users = {User.DEFAULT_USER, User.GHOST_USER, User.AI_USER, User.SECURITY_USER}
|
||||
real_user_count = User.query().filter(~User.username.in_(system_users)).count()
|
||||
is_first_user = real_user_count == 0
|
||||
|
||||
form_data = {
|
||||
"username": username,
|
||||
"password": secrets.token_hex(32),
|
||||
"email": email,
|
||||
"firstname": "",
|
||||
"lastname": "",
|
||||
"active": True if is_first_user else auto_active,
|
||||
"admin": is_first_user,
|
||||
}
|
||||
|
||||
extern_name = auth_rhodecode.RhodeCodeAuthPlugin.uid
|
||||
extern_type = auth_rhodecode.RhodeCodeAuthPlugin.uid
|
||||
|
||||
new_user = UserModel().create_registration(form_data, extern_name=extern_name, extern_type=extern_type)
|
||||
|
||||
action_data = {"data": new_user.get_api_data(), "user_agent": self.request.user_agent}
|
||||
audit_user = audit_logger.UserWrap(
|
||||
username=new_user.username, user_id=new_user.user_id, ip_addr=self.request.remote_addr
|
||||
)
|
||||
audit_logger.store_web("user.register", action_data=action_data, user=audit_user)
|
||||
|
||||
event = UserRegistered(user=new_user, session=self.session)
|
||||
trigger(event)
|
||||
Session().commit()
|
||||
|
||||
# Clean up OTP session data
|
||||
self._clear_otp_session()
|
||||
|
||||
if is_first_user or auto_active:
|
||||
headers = store_user_in_session(self.session, user_identifier=email, remember=False)
|
||||
h.flash(_("Your account has been created. Welcome!"), category="success")
|
||||
raise HTTPFound(h.route_path("home"), headers=headers)
|
||||
else:
|
||||
h.flash(_("Your account has been created and awaits admin approval."), category="success")
|
||||
raise HTTPFound(self.request.route_path("login"))
|
||||
|
||||
def _clear_otp_session(self):
|
||||
for key in list(self.session.keys()):
|
||||
if key.startswith("otp_"):
|
||||
self.session.pop(key, None)
|
||||
if hasattr(self.session, 'save'):
|
||||
self.session.save()
|
||||
|
||||
# ---- Resend OTP ----
|
||||
|
||||
def resend_otp(self):
|
||||
email = self.session.get("otp_email")
|
||||
if not email:
|
||||
return {"status": "error", "message": "No pending verification."}
|
||||
|
||||
# Rate limit: one per 60s
|
||||
last_sent = self.session.get("otp_last_sent", 0)
|
||||
if time.time() - last_sent < 60:
|
||||
return {"status": "error", "message": "Please wait before requesting a new code."}
|
||||
|
||||
is_new_user = self.session.get("otp_is_new_user", False)
|
||||
|
||||
if is_new_user:
|
||||
# New user: regenerate OTP stored in session
|
||||
code = ''.join(str(secrets.randbelow(10)) for _ in range(6))
|
||||
code_hash = hashlib.sha256(code.encode('utf-8')).hexdigest()
|
||||
self.session["otp_hash"] = code_hash
|
||||
self.session["otp_created_at"] = time.time()
|
||||
self.session["otp_attempts"] = 0
|
||||
else:
|
||||
# Existing user: regenerate on the user object
|
||||
user = User.get_by_email(email)
|
||||
if user and user.active:
|
||||
code = user.generate_otp("login")
|
||||
Session().commit()
|
||||
else:
|
||||
# Fake success to prevent enumeration
|
||||
self.session["otp_last_sent"] = time.time()
|
||||
if hasattr(self.session, 'save'):
|
||||
self.session.save()
|
||||
return {"status": "ok", "message": "Code sent."}
|
||||
|
||||
self._send_otp_email(email, code, "verification")
|
||||
self.session["otp_last_sent"] = time.time()
|
||||
if hasattr(self.session, 'save'):
|
||||
self.session.save()
|
||||
return {"status": "ok", "message": "Code sent."}
|
||||
|
||||
# ---- Logout ----
|
||||
|
||||
@CSRFRequired()
|
||||
def logout(self):
|
||||
|
|
@ -222,296 +456,51 @@ class LoginView(BaseAppView):
|
|||
self.session.delete()
|
||||
return HTTPFound(h.route_path("home"))
|
||||
|
||||
@HasPermissionAnyDecorator("hg.admin", "hg.register.auto_activate", "hg.register.manual_activate")
|
||||
# ---- Registration (redirects to unified login flow) ----
|
||||
|
||||
def register(self, defaults=None, errors=None):
|
||||
raise HTTPFound(self.request.route_path("login"))
|
||||
|
||||
# ---- Sudo OTP ----
|
||||
|
||||
@LoginRequired()
|
||||
@NotAnonymous()
|
||||
def sudo_otp_verify(self):
|
||||
c = self.load_default_context()
|
||||
defaults = defaults or {}
|
||||
errors = errors or {}
|
||||
return self._get_template_context(c)
|
||||
|
||||
settings = SettingsModel().get_all_settings()
|
||||
register_message = settings.get("rhodecode_register_message") or ""
|
||||
captcha = self._get_captcha_data()
|
||||
auto_active = "hg.register.auto_activate" in User.get_default_user().AuthUser().permissions["global"]
|
||||
@LoginRequired()
|
||||
@NotAnonymous()
|
||||
@CSRFRequired()
|
||||
def sudo_otp_verify_post(self):
|
||||
c = self.load_default_context()
|
||||
user = self._rhodecode_db_user
|
||||
|
||||
render_ctx = self._get_template_context(c)
|
||||
render_ctx.update(
|
||||
{
|
||||
"defaults": defaults,
|
||||
"errors": errors,
|
||||
"auto_active": auto_active,
|
||||
"captcha_active": captcha.active,
|
||||
"captcha_public_key": captcha.public_key,
|
||||
"register_message": register_message,
|
||||
}
|
||||
)
|
||||
return render_ctx
|
||||
otp_form = OTPVerifyForm(self.request.translate, digits=8)()
|
||||
|
||||
@HasPermissionAnyDecorator("hg.admin", "hg.register.auto_activate", "hg.register.manual_activate")
|
||||
def register_post(self):
|
||||
from rhodecode.authentication.plugins import auth_rhodecode
|
||||
|
||||
self.load_default_context()
|
||||
captcha = self._get_captcha_data()
|
||||
auto_active = "hg.register.auto_activate" in User.get_default_user().AuthUser().permissions["global"]
|
||||
|
||||
extern_name = auth_rhodecode.RhodeCodeAuthPlugin.uid
|
||||
extern_type = auth_rhodecode.RhodeCodeAuthPlugin.uid
|
||||
|
||||
register_form = RegisterForm(self.request.translate)()
|
||||
try:
|
||||
form_result = register_form.to_python(self.request.POST)
|
||||
form_result["active"] = auto_active
|
||||
external_identity = self.request.POST.get("external_identity")
|
||||
form_result = otp_form.to_python(self.request.POST)
|
||||
code = form_result["otp"]
|
||||
|
||||
if external_identity:
|
||||
extern_name = external_identity
|
||||
extern_type = external_identity
|
||||
if not user.verify_otp(code, "sudo"):
|
||||
Session().commit()
|
||||
error_msg = _("Invalid or expired code. Please try again.")
|
||||
raise formencode.Invalid(error_msg, form_result, None,
|
||||
error_dict={"otp": error_msg})
|
||||
|
||||
if captcha.active:
|
||||
captcha_status, captcha_message = self.validate_captcha(captcha.private_key)
|
||||
|
||||
if not captcha_status:
|
||||
_value = form_result
|
||||
_msg = _("Bad captcha")
|
||||
error_dict = {"recaptcha_field": captcha_message}
|
||||
raise formencode.Invalid(_msg, _value, None, error_dict=error_dict)
|
||||
|
||||
new_user = UserModel().create_registration(form_result, extern_name=extern_name, extern_type=extern_type)
|
||||
|
||||
action_data = {"data": new_user.get_api_data(), "user_agent": self.request.user_agent}
|
||||
|
||||
if external_identity:
|
||||
action_data["external_identity"] = external_identity
|
||||
|
||||
audit_user = audit_logger.UserWrap(
|
||||
username=new_user.username, user_id=new_user.user_id, ip_addr=self.request.remote_addr
|
||||
)
|
||||
|
||||
audit_logger.store_web("user.register", action_data=action_data, user=audit_user)
|
||||
|
||||
event = UserRegistered(user=new_user, session=self.session)
|
||||
trigger(event)
|
||||
h.flash(_("You have successfully registered with RhodeCode. You can log-in now."), category="success")
|
||||
if external_identity:
|
||||
h.flash(
|
||||
_("Please use the {identity} button to log-in").format(identity=external_identity),
|
||||
category="success",
|
||||
)
|
||||
Session().commit()
|
||||
|
||||
redirect_ro = self.request.route_path("login")
|
||||
raise HTTPFound(redirect_ro)
|
||||
self.session["sudo_otp_verified_at"] = time.time()
|
||||
next_url = self.session.pop("sudo_next_url", h.route_path("home"))
|
||||
if hasattr(self.session, 'save'):
|
||||
self.session.save()
|
||||
|
||||
raise HTTPFound(next_url)
|
||||
|
||||
except formencode.Invalid as errors:
|
||||
errors.value.pop("password", None)
|
||||
errors.value.pop("password_confirmation", None)
|
||||
return self.register(defaults=errors.value, errors=errors.error_dict)
|
||||
|
||||
except UserCreationError as e:
|
||||
# container auth or other auth functions that create users on
|
||||
# the fly can throw this exception signaling that there's issue
|
||||
# with user creation, explanation should be provided in
|
||||
# Exception itself
|
||||
h.flash(e, category="error")
|
||||
return self.register()
|
||||
|
||||
def password_reset(self):
|
||||
c = self.load_default_context()
|
||||
captcha = self._get_captcha_data()
|
||||
|
||||
template_context = {
|
||||
"captcha_active": captcha.active,
|
||||
"captcha_public_key": captcha.public_key,
|
||||
"defaults": {},
|
||||
"errors": {},
|
||||
}
|
||||
|
||||
# always send implicit message to prevent from discovery of
|
||||
# matching emails
|
||||
msg = _("If such email exists, a password reset link was sent to it.")
|
||||
|
||||
def default_response():
|
||||
log.debug("faking response on invalid password reset")
|
||||
# make this take 2s, to prevent brute forcing.
|
||||
time.sleep(2)
|
||||
h.flash(msg, category="success")
|
||||
return HTTPFound(self.request.route_path("reset_password"))
|
||||
|
||||
if self.request.POST:
|
||||
if h.HasPermissionAny("hg.password_reset.disabled")():
|
||||
_email = self.request.POST.get("email", "")
|
||||
log.error("Failed attempt to reset password for `%s`.", _email)
|
||||
h.flash(_("Password reset has been disabled."), category="error")
|
||||
return HTTPFound(self.request.route_path("reset_password"))
|
||||
|
||||
password_reset_form = PasswordResetForm(self.request.translate)()
|
||||
description = "Generated token for password reset from {}".format(datetime.datetime.now().isoformat())
|
||||
|
||||
try:
|
||||
form_result = password_reset_form.to_python(self.request.POST)
|
||||
user_email = form_result["email"]
|
||||
|
||||
if captcha.active:
|
||||
captcha_status, captcha_message = self.validate_captcha(captcha.private_key)
|
||||
|
||||
if not captcha_status:
|
||||
_value = form_result
|
||||
_msg = _("Bad captcha")
|
||||
error_dict = {"recaptcha_field": captcha_message}
|
||||
raise formencode.Invalid(_msg, _value, None, error_dict=error_dict)
|
||||
|
||||
# Generate reset URL and send mail.
|
||||
user = User.get_by_email(user_email)
|
||||
|
||||
# only allow rhodecode based users to reset their password
|
||||
# external auth shouldn't allow password reset
|
||||
if user and user.extern_type != auth_rhodecode.RhodeCodeAuthPlugin.uid:
|
||||
log.warning(
|
||||
"User %s with external type `%s` tried a password reset. This try was rejected",
|
||||
user,
|
||||
user.extern_type,
|
||||
)
|
||||
return default_response()
|
||||
|
||||
# generate password reset token that expires in 10 minutes
|
||||
reset_token = UserModel().add_auth_token(
|
||||
user=user,
|
||||
lifetime_minutes=10,
|
||||
role=UserModel.auth_token_role.ROLE_PASSWORD_RESET,
|
||||
description=description,
|
||||
)
|
||||
Session().commit()
|
||||
|
||||
log.debug("Successfully created password recovery token")
|
||||
password_reset_url = self.request.route_url(
|
||||
"reset_password_confirmation", _query={"key": reset_token.api_key}
|
||||
)
|
||||
UserModel().reset_password_link(form_result, password_reset_url)
|
||||
|
||||
action_data = {"email": user_email, "user_agent": self.request.user_agent}
|
||||
audit_logger.store_web(
|
||||
"user.password.reset_request", action_data=action_data, user=self._rhodecode_user, commit=True
|
||||
)
|
||||
|
||||
return default_response()
|
||||
|
||||
except formencode.Invalid as errors:
|
||||
template_context.update(
|
||||
{
|
||||
"defaults": errors.value,
|
||||
"errors": errors.error_dict,
|
||||
}
|
||||
)
|
||||
if not self.request.POST.get("email"):
|
||||
# case of empty email, we want to report that
|
||||
return self._get_template_context(c, **template_context)
|
||||
|
||||
if "recaptcha_field" in errors.error_dict:
|
||||
# case of failed captcha
|
||||
return self._get_template_context(c, **template_context)
|
||||
|
||||
return default_response()
|
||||
|
||||
return self._get_template_context(c, **template_context)
|
||||
|
||||
def password_reset_confirmation(self):
|
||||
self.load_default_context()
|
||||
|
||||
if key := self.request.GET.get("key"):
|
||||
# make this take 2s, to prevent brute forcing.
|
||||
time.sleep(2)
|
||||
|
||||
token = AuthTokenModel().get_auth_token(key)
|
||||
|
||||
# verify token is the correct role
|
||||
if token is None or token.role != UserApiKeys.ROLE_PASSWORD_RESET:
|
||||
log.debug(
|
||||
"Got token with role:%s expected is %s",
|
||||
getattr(token, "role", "EMPTY_TOKEN"),
|
||||
UserApiKeys.ROLE_PASSWORD_RESET,
|
||||
)
|
||||
h.flash(_("Given reset token is invalid"), category="error")
|
||||
return HTTPFound(self.request.route_path("reset_password"))
|
||||
|
||||
try:
|
||||
owner = token.user
|
||||
data = {"email": owner.email, "token": token.api_key}
|
||||
UserModel().reset_password(data)
|
||||
h.flash(
|
||||
_("Your password reset was successful, a new password has been sent to your email"),
|
||||
category="success",
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(e)
|
||||
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":
|
||||
post_items = dict(self.request.POST)
|
||||
|
||||
try:
|
||||
form_details = form.to_python(post_items)
|
||||
secret = form_details["secret_totp"]
|
||||
|
||||
user_instance.init_2fa_recovery_codes(persist=True, force=True)
|
||||
user_instance.secret_2fa = secret
|
||||
|
||||
Session().commit()
|
||||
raise HTTPFound(self.request.route_path("my_account_configure_2fa", _query={"show-recovery-codes": 1}))
|
||||
except formencode.Invalid as errors:
|
||||
defaults = errors.value
|
||||
render_ctx = {
|
||||
"errors": errors.error_dict,
|
||||
"defaults": defaults,
|
||||
}
|
||||
|
||||
# NOTE: here we DO NOT persist the secret 2FA, since this is only for setup, once a setup is completed
|
||||
# only then we should persist it
|
||||
secret = user_instance.init_secret_2fa(persist=False)
|
||||
|
||||
instance_name = rhodecode.ConfigGet().get_str("app.base_url", "rhodecode")
|
||||
totp_name = f"{instance_name}:{self.request.user.username}"
|
||||
|
||||
qr = qrcode.QRCode(version=1, box_size=5, border=4)
|
||||
qr.add_data(pyotp.totp.TOTP(secret).provisioning_uri(name=totp_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, totp_name=totp_name, **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":
|
||||
post_items = dict(self.request.POST)
|
||||
# NOTE: inject secret, as it's a post configured saved item.
|
||||
post_items["secret_totp"] = user_instance.secret_2fa
|
||||
try:
|
||||
totp_form.to_python(post_items)
|
||||
user_instance.check_2fa_required = 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)
|
||||
defaults = errors.value
|
||||
render_ctx = {
|
||||
"errors": errors.error_dict,
|
||||
"defaults": defaults,
|
||||
}
|
||||
return self._get_template_context(c, **render_ctx)
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ from rhodecode.model.db import (
|
|||
RepoGroup,
|
||||
ChangesetStatus,
|
||||
)
|
||||
from rhodecode.model.forms import TOTPForm
|
||||
from rhodecode.model.meta import Session
|
||||
from rhodecode.model.pull_request import PullRequestModel
|
||||
from rhodecode.model.user import UserModel
|
||||
|
|
@ -257,26 +256,8 @@ class MyAccountView(BaseAppView, DataGridAppView):
|
|||
@CSRFRequired()
|
||||
def my_account_2fa_regenerate_recovery_codes(self):
|
||||
_ = self.request.translate
|
||||
c = self.load_default_context()
|
||||
user_instance = c.auth_user.get_instance()
|
||||
|
||||
totp_form = TOTPForm(_, user_instance, allow_recovery_code_use=True)()
|
||||
|
||||
post_items = dict(self.request.POST)
|
||||
# NOTE: inject secret, as it's a post configured saved item.
|
||||
post_items["secret_totp"] = user_instance.secret_2fa
|
||||
try:
|
||||
totp_form.to_python(post_items)
|
||||
user_instance.regenerate_2fa_recovery_codes()
|
||||
Session().commit()
|
||||
except formencode.Invalid as errors:
|
||||
h.flash(_("Failed to generate new recovery codes: {}").format(errors), category="error")
|
||||
raise HTTPFound(self.request.route_path("my_account_configure_2fa"))
|
||||
except Exception as e:
|
||||
h.flash(_("Failed to generate new recovery codes: {}").format(e), category="error")
|
||||
raise HTTPFound(self.request.route_path("my_account_configure_2fa"))
|
||||
|
||||
raise HTTPFound(self.request.route_path("my_account_configure_2fa", _query={"show-recovery-codes": 1}))
|
||||
h.flash(_("2FA recovery codes are no longer used."), category="warning")
|
||||
raise HTTPFound(self.request.route_path("my_account_configure_2fa"))
|
||||
|
||||
@LoginRequired()
|
||||
@NotAnonymous()
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ from rhodecode.lib.auth import (
|
|||
NotAnonymous,
|
||||
CSRFRequired,
|
||||
)
|
||||
from rhodecode.lib.auth_otp import SudoOTPRequired
|
||||
from rhodecode.lib.utils2 import str2bool, safe_str, safe_int, aslist, retry
|
||||
from rhodecode.lib.vcs.backends.base import EmptyCommit, UpdateFailureReason, unicode_to_reference
|
||||
from rhodecode.lib.vcs.exceptions import CommitDoesNotExistError, RepositoryRequirementError, EmptyRepositoryError
|
||||
|
|
@ -1678,6 +1679,7 @@ class RepoPullRequestsView(RepoAppView, DataGridAppView):
|
|||
@NotAnonymous()
|
||||
@HasRepoPermissionAnyDecorator("repository.read", "repository.write", "repository.admin")
|
||||
@CSRFRequired()
|
||||
@SudoOTPRequired
|
||||
def pull_request_merge(self):
|
||||
"""
|
||||
Merge will perform a server-side merge of the specified
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from rhodecode.apps._base import RepoAppView
|
|||
from rhodecode.lib import helpers as h
|
||||
from rhodecode.lib import audit_logger
|
||||
from rhodecode.lib.auth import LoginRequired, HasRepoPermissionAnyDecorator, CSRFRequired, HasRepoPermissionAny
|
||||
from rhodecode.lib.auth_otp import SudoOTPRequired
|
||||
from rhodecode.lib.exceptions import AttachedForksError, AttachedPullRequestsError, AttachedArtifactsError
|
||||
from rhodecode.lib.type_utils import str2bool
|
||||
from rhodecode.lib.utils2 import safe_int
|
||||
|
|
@ -142,6 +143,7 @@ class RepoSettingsAdvancedView(RepoAppView):
|
|||
@LoginRequired()
|
||||
@HasRepoPermissionAnyDecorator("repository.admin")
|
||||
@CSRFRequired()
|
||||
@SudoOTPRequired
|
||||
def edit_advanced_delete(self):
|
||||
"""
|
||||
Deletes the repository, or shows warnings if deletion is not possible
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import click
|
|||
|
||||
from rhodecode.lib.pyramid_utils import bootstrap
|
||||
from rhodecode.lib.statsd_client import StatsdClient
|
||||
from rhodecode.config.patches import suppress_pkg_resources_warnings
|
||||
|
||||
from .backends import SshWrapper
|
||||
from .utils import setup_custom_logging
|
||||
|
||||
|
|
@ -53,9 +53,6 @@ log = logging.getLogger(__name__)
|
|||
@click.option("--shell", "-s", is_flag=True, help="Allow Shell")
|
||||
@click.option("--debug", is_flag=True, help="Enabled detailed output logging")
|
||||
def main(ini_path, mode, user, user_id, key_id, shell, debug):
|
||||
# Apply pkg_resources deprecation warning suppression early
|
||||
suppress_pkg_resources_warnings()
|
||||
|
||||
setup_custom_logging(ini_path, debug)
|
||||
|
||||
command = os.environ.get("SSH_ORIGINAL_COMMAND", "")
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import logging
|
|||
import click
|
||||
|
||||
from rhodecode.config.config_maker import sanitize_settings_and_apply_defaults
|
||||
from rhodecode.config.patches import suppress_pkg_resources_warnings
|
||||
|
||||
from rhodecode.lib.request import Request
|
||||
from rhodecode.lib.utils2 import AttributeDict
|
||||
from rhodecode.lib.statsd_client import StatsdClient
|
||||
|
|
@ -65,9 +65,6 @@ log = logging.getLogger(__name__)
|
|||
@click.option("--shell", "-s", is_flag=True, help="Allow Shell")
|
||||
@click.option("--debug", is_flag=True, help="Enabled detailed output logging")
|
||||
def main(ini_path, mode, user, user_id, key_id, shell, debug):
|
||||
# Apply pkg_resources deprecation warning suppression early
|
||||
suppress_pkg_resources_warnings()
|
||||
|
||||
time_start = time.time()
|
||||
setup_custom_logging(ini_path, debug)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue