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:
russell@unturf.com 2026-03-04 16:44:40 -05:00
parent 1dac65fc8a
commit 722c3bd369
38 changed files with 4100 additions and 1835 deletions

View file

@ -1017,6 +1017,53 @@ class User(Base, BaseModel):
Session().commit()
return new_recovery_codes
def generate_otp(self, purpose, digits=6):
import secrets
import hashlib
code = ''.join(str(secrets.randbelow(10)) for _ in range(digits))
code_hash = hashlib.sha256(code.encode('utf-8')).hexdigest()
self.update_userdata(
otp_code=code_hash,
otp_created_at=time.time(),
otp_attempts=0,
otp_purpose=purpose,
)
return code
def verify_otp(self, code, purpose, max_age=300, max_attempts=5):
import hashlib
stored_hash = self.user_data.get('otp_code')
created_at = self.user_data.get('otp_created_at', 0)
attempts = self.user_data.get('otp_attempts', 0)
stored_purpose = self.user_data.get('otp_purpose')
if not stored_hash or stored_purpose != purpose:
return False
if attempts >= max_attempts:
self.clear_otp()
return False
if (time.time() - created_at) > max_age:
self.clear_otp()
return False
self.update_userdata(otp_attempts=attempts + 1)
code_hash = hashlib.sha256(code.encode('utf-8')).hexdigest()
if code_hash == stored_hash:
self.clear_otp()
return True
return False
def clear_otp(self):
self.update_userdata(
otp_code=None,
otp_created_at=None,
otp_attempts=None,
otp_purpose=None,
)
@classmethod
def extra_valid_auth_tokens(cls, user, role=None):
tokens = (

View file

@ -86,54 +86,22 @@ def LoginForm(localizer):
class _LoginForm(formencode.Schema):
allow_extra_fields = True
filter_extra_fields = True
username = v.UnicodeString(
strip=True,
min=1,
not_empty=True,
messages={
"empty": _("Please enter a login"),
"tooShort": _("Enter a value %(min)i characters long or more"),
},
)
password = v.UnicodeString(
strip=False,
min=3,
max=72,
not_empty=True,
messages={"empty": _("Please enter a password"), "tooShort": _("Enter %(min)i characters or more")},
)
email = All(v.Email(not_empty=True), v.UnicodeString(strip=True, min=1, not_empty=True))
remember = v.StringBoolean(if_missing=False)
chained_validators = [v.ValidAuth(localizer)]
return _LoginForm
def TOTPForm(localizer, user, allow_recovery_code_use=False):
def OTPVerifyForm(localizer, digits=6):
_ = localizer
class _TOTPForm(formencode.Schema):
class _OTPVerifyForm(formencode.Schema):
allow_extra_fields = True
filter_extra_fields = False
totp = v.Regex(r"^(?:\d{6}|[A-Z0-9]{32})$")
secret_totp = v.String()
filter_extra_fields = True
otp = v.Regex(r'^\d{%d}$' % digits, not_empty=True,
messages={'invalid': _('Please enter a valid %d-digit code') % digits})
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"]
secret = form_data.get("secret_totp")
if not any(map(lambda func: func(received_code, secret), validation_checks)):
error_msg = _("Code is invalid. Try again!")
raise formencode.Invalid(error_msg, v, state, error_dict={"totp": error_msg})
return form_data
return _TOTPForm
return _OTPVerifyForm
def WhitelistedVcsClientsForm(localizer):
@ -246,32 +214,11 @@ def RegisterForm(localizer, edit=False, old_data=None):
class _RegisterForm(formencode.Schema):
allow_extra_fields = True
filter_extra_fields = True
username = All(v.ValidUsername(localizer, edit, old_data), v.UnicodeString(strip=True, min=1, not_empty=True))
password = All(v.ValidPassword(localizer), v.UnicodeString(strip=False, min=6, max=72, not_empty=True))
password_confirmation = All(
v.ValidPassword(localizer), v.UnicodeString(strip=False, min=6, max=72, not_empty=True)
)
active = v.StringBoolean(if_missing=False)
firstname = v.UnicodeString(strip=True, min=1, not_empty=False)
lastname = v.UnicodeString(strip=True, min=1, not_empty=False)
email = All(v.UniqSystemEmail(localizer, old_data), v.Email(not_empty=True))
chained_validators = [v.ValidPasswordsMatch(localizer)]
return _RegisterForm
def PasswordResetForm(localizer):
_ = localizer
class _PasswordResetForm(formencode.Schema):
allow_extra_fields = True
filter_extra_fields = True
email = All(v.ValidSystemEmail(localizer), v.Email(not_empty=True))
return _PasswordResetForm
def RepoForm(localizer, edit=False, old_data=None, repo_groups=None, allow_disabled=False):
_ = localizer
old_data = old_data or {}

View file

@ -378,13 +378,12 @@ class EmailNotificationModel(BaseModel):
TYPE_PULL_REQUEST_UPDATE = Notification.TYPE_PULL_REQUEST_UPDATE
TYPE_MAIN = Notification.TYPE_MESSAGE
TYPE_PASSWORD_RESET = "password_reset"
TYPE_PASSWORD_RESET_CONFIRMATION = "password_reset_confirmation"
TYPE_EMAIL_TEST = "email_test"
TYPE_EMAIL_EXCEPTION = "exception"
TYPE_UPDATE_AVAILABLE = "update_available"
TYPE_TEST = "test"
TYPE_SECURITY_AUDIT = "security_audit"
TYPE_OTP_CODE = "otp_code"
email_types = {
TYPE_MAIN: "rhodecode:templates/email_templates/main.mako",
@ -393,13 +392,12 @@ class EmailNotificationModel(BaseModel):
TYPE_UPDATE_AVAILABLE: "rhodecode:templates/email_templates/update_available.mako",
TYPE_EMAIL_TEST: "rhodecode:templates/email_templates/email_test.mako",
TYPE_REGISTRATION: "rhodecode:templates/email_templates/user_registration.mako",
TYPE_PASSWORD_RESET: "rhodecode:templates/email_templates/password_reset.mako",
TYPE_PASSWORD_RESET_CONFIRMATION: "rhodecode:templates/email_templates/password_reset_confirmation.mako",
TYPE_COMMIT_COMMENT: "rhodecode:templates/email_templates/commit_comment.mako",
TYPE_PULL_REQUEST: "rhodecode:templates/email_templates/pull_request_review.mako",
TYPE_PULL_REQUEST_COMMENT: "rhodecode:templates/email_templates/pull_request_comment.mako",
TYPE_PULL_REQUEST_UPDATE: "rhodecode:templates/email_templates/pull_request_update.mako",
TYPE_SECURITY_AUDIT: "rhodecode:templates/email_templates/security_audit.mako",
TYPE_OTP_CODE: "rhodecode:templates/email_templates/otp_code.mako",
}
premailer_instance = premailer.Premailer(

View file

@ -423,37 +423,6 @@ def ValidPasswordsMatch(localizer, passwd="new_password", passwd_confirmation="p
return _validator
def ValidAuth(localizer):
_ = localizer
class _validator(formencode.validators.FancyValidator):
messages = {
"invalid_password": _("invalid password"),
"invalid_username": _("invalid user name"),
"disabled_account": _("Your account is disabled"),
}
def _validate_python(self, value, state):
from rhodecode.authentication.base import authenticate, HTTP_TYPE
password = value["password"]
username = value["username"]
if not authenticate(username, password, "", HTTP_TYPE, skip_missing=True):
user = User.get_by_username_or_primary_email(username)
if user and not user.active:
log.warning("user %s is disabled", username)
msg = M(self, "disabled_account", state)
raise formencode.Invalid(msg, value, state, error_dict={"username": msg})
else:
log.warning("user `%s` failed to authenticate", username)
msg = M(self, "invalid_username", state)
msg2 = M(self, "invalid_password", state)
raise formencode.Invalid(msg, value, state, error_dict={"username": msg, "password": msg2})
return _validator
def ValidRepoName(localizer, edit=False, old_data=None):
old_data = old_data or {}
_ = localizer