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
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue