THREESCALE-8916: Make strong passwords mandatory#4195
THREESCALE-8916: Make strong passwords mandatory#4195jlledom wants to merge 28 commits into3scale:masterfrom
Conversation
da18ea7 to
5b00c7f
Compare
| const RE_STRONG_PASSWORD = new RegExp( | ||
| '^' + | ||
| '(?=.*\\d)' + // at least one digit | ||
| '(?=.*[a-z])' + // at least one lowercase | ||
| '(?=.*[A-Z])' + // at least one uppercase | ||
| `(?=.*[${STRONG_PASSWORD_SPECIAL_CHARS.replace(/[[\]\\]/g, '\\$&')}])` + // at least one special char | ||
| '(?!.*\\s)' + // no whitespace | ||
| `.{${STRONG_PASSWORD_MIN_SIZE},}` + // minimum length | ||
| '$' | ||
| ) |
There was a problem hiding this comment.
According to NIST guidelines, passwords have to be long, not contain funny characters. I think we should have a default password setting of maybe 12 characters and allow customers to set their own REGEX. This will also allow us to keep short passwords in dev mode so we don't have to type funny stuff to login local server when developing 😎
We better read about current best practices though.
There was a problem hiding this comment.
So currently NIST seems to care about password length and expiration is not recommended. I think standard was 14 chars at the moment without character enforcement. Maybe we should have a setting to allow customers setting minimal password length. idk if we should just keep what we had before and have standard 14 chars, and when strong password chosen, then go for 16 chars. Adding a new field for a custom length is probably an overkill. Lets discuss further.
There was a problem hiding this comment.
We should follow NIST SP 800-63B-4 Date Published: July 2025
https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-63B-4.pdf
See section 3.1.1.2. Password Verifiers
Maybe we don't even need the strong password setting.
| self.password = unencrypted_password | ||
| ThreeScale::Analytics.user_tracking(self).track('Migrated to BCrypt') | ||
| update_columns(password_digest: password_digest, salt: nil, crypted_password: nil) | ||
| authenticate(unencrypted_password) |
There was a problem hiding this comment.
I feel uneasy about this whole file. What is it doing? Why is it wrapping? What was super?
app/models/user.rb
Outdated
|
|
||
| def password_required? | ||
| signup.by_user? && super | ||
| (signup.by_user? || using_password?) && super |
There was a problem hiding this comment.
can you explain why do we need this change?
There was a problem hiding this comment.
Can we rename this method? What is super? I wonder if we should separate usage of password on signup and for validation.
There was a problem hiding this comment.
I asked AI whether the method can be split for checking whether password fields should be shown and whether password field has to be validated. Idk if the analysis makes sense but here it is:
● You're absolutely right - this is a confusing design! The same password_required? method is being used for two different purposes:
1. Model validation - "Should we validate password presence when saving?"
2. UI rendering - "Should we show password fields in forms?"
And worse, it's got that triple-level super chain that's hard to follow.
Better Design
This could be much clearer with separate methods:
For Validation:
# In User model
def validate_password_presence?
signup.by_user? && password_digest_requires_validation? && legacy_password_requires_validation?
end
validates_presence_of :password, if: :validate_password_presence?
For UI:
# In User model
def needs_password_field?
signup.by_user? # Or even more specific: !using_external_auth?
end
# In templates
{% if user.needs_password_field? %}
<input name="account[user][password]" type="password">
{% endif %}
Why the current design exists
Looking at the code, this seems to be a result of:
1. Legacy migration - Moving from crypted_password to password_digest (bcrypt)
2. Monkey-patching Rails - Overriding has_secure_password's password_required?
3. No refactoring - Just kept adding super chains instead of cleaning it up
The method does too many things and the name doesn't clearly indicate which concern it addresses.
| public_search account_plans_ui_visible change_account_plan_permission service_plans_ui_visible | ||
| change_service_plan_permission enforce_sso | ||
| useraccountarea_enabled hide_service signups_enabled account_approval_required public_search | ||
| account_plans_ui_visible change_account_plan_permission service_plans_ui_visiblechange_service_plan_permission |
There was a problem hiding this comment.
| account_plans_ui_visible change_account_plan_permission service_plans_ui_visiblechange_service_plan_permission | |
| account_plans_ui_visible change_account_plan_permission service_plans_ui_visible change_service_plan_permission |
33fe198 to
25f4a95
Compare
101f7d4 to
7c97a77
Compare
- Validate strong passwords in React forms - Some forms treat the password field as clear text. - Make password fields required only when they are actually required. - `Current password` field visible and required only when it should. - Password can be set for all users, no matter how did they signup. - SSO Users: don't see the toast to change password. It can be changed from the same form This fixes https://issues.redhat.com/browse/THREESCALE-11548 - When enforce SSO is enabled, passwords can still be set or changed.
f864657 to
891e8cb
Compare
This is to exclude John Doe from strong passwords validation Co-Authored-By: Claude <noreply@anthropic.com>
891e8cb to
35acc2b
Compare
| authorize! :update, user | ||
|
|
||
| user.update_with_flattened_attributes(flat_params) | ||
| user.update_with_flattened_attributes(flat_params, as: current_user.try(:role)) |
There was a problem hiding this comment.
PUT /admin/api/users/{id}.xml is adding this to it's call. This endpoint is the equivalent but for buyer users: PUT /admin/api/accounts/{id}/users/{id}.xml.
This is to unify behavior.
There was a problem hiding this comment.
I am not sure this is relevant here. From what I can see this as: is used to prevent member permissions from being updated by member users - it's only allowed to admin users:
Lines 112 to 116 in 986e1a3
However, these member permissions thing is only applicable to admin users, not to buyer users, so I don't think adding as: changes anything.
It is true that on the code level there is no distinction between buyer users and admin users...
But I wouldn't add this 🤷
There was a problem hiding this comment.
But I wouldn't add this 🤷
By "this" you mean the as: in buyers_users_controller.rb:40? or the as: in user.rb:116?
Do you want me to remove this change?
There was a problem hiding this comment.
I meant as: current_user.try(:role) in this controller. Unless this breaks something, of course 🤔
Well, I don't know if we need to remove it, I just find it a bit confusing to have it here, as it is not supposed to have any function for this controller, from what I understand.
| { | ||
| signup_type: partner.signup_type, | ||
| password: permitted_params[:password].presence || SecureRandom.hex, | ||
| password: permitted_params[:password].presence, |
There was a problem hiding this comment.
The user is valid without a password when it's an SSO user. So there's no need to enforce a random password. Also, this random password is not shown to the caller anywhere, so it couldn't be used anyway.
After this change, The SSO users for partners don't have a password, the same as any other SSO user in the project.
| @user = @account.users.build | ||
| @user.email = params[:email] | ||
| @user.password = SecureRandom.hex | ||
| @user.password = params[:password].presence |
There was a problem hiding this comment.
Same, no password required.
| // IMPORTANT: These STRONG_PASSWORD_* constants are duplicated from the backend. | ||
| // The source of truth is app/lib/authentication/by_password.rb. If those constants change in Ruby, | ||
| // you must update them here as well. Do not modify these without updating the backend first. | ||
| const STRONG_PASSWORD_MIN_SIZE = 16 | ||
| const RE_STRONG_PASSWORD = new RegExp(`^[ -~]{${STRONG_PASSWORD_MIN_SIZE},64}$`) | ||
| const STRONG_PASSWORD_FAIL_MSG = `Password must be at least ${STRONG_PASSWORD_MIN_SIZE} characters long, and contain only valid characters.` | ||
|
|
There was a problem hiding this comment.
Enforce the same NIST policy in frontend that we enforce in backend
| validates :lost_password_token, :password_digest, length: { maximum: 255 } | ||
|
|
||
| attr_accessible :password, :password_confirmation | ||
| attr_accessible :password, :password_confirmation, as: %i[default member admin] |
There was a problem hiding this comment.
This is to fix a mass-assignment error from PUT /admin/api/users/{id}.xml. Since the endpoint calls the update with role: :admin. The password was excluded from mass-assignment, because it only allowed the default role.
There was a problem hiding this comment.
I'm not sure I understand... if there is no as: argument here, doesn't it mean that the role set in :as when assigning attributes doesn't matter?
| def validate_password? | ||
| password_digest_changed? || (signup.by_user? && password_digest.blank?) | ||
| end | ||
|
|
||
| def validate_strong_password? | ||
| return false if Rails.configuration.three_scale.strong_passwords_disabled | ||
| return false if signup.sample_data? | ||
|
|
||
| validate_password? | ||
| end |
There was a problem hiding this comment.
This replaces password_required? Because it was pretty confusing:
- Being called from views to decide whether making password inputs required. IMO that's wrong, password inputs are required or not according to their purpose, not to some computed value.
- Being called also to decide whether validate the password or not. Which was wrong as well, since it didn't match all scenarios.
The new methods are tested and return proper values for all known scenarios.
| email_part = email.split('@') | ||
| user_attributes = { email: "#{email_part[0]}+test@#{email_part[1]}", username: 'john', first_name: 'John', | ||
| last_name: 'Doe', password: '123456', password_confirmation: '123456', signup_type: :minimal} | ||
| last_name: 'Doe', password: '123456', password_confirmation: '123456', signup_type: :sample_data} |
There was a problem hiding this comment.
In order to exclude "John Doe" from strong password requirement, I added a new signup type :sample_data to identify it.
There was a problem hiding this comment.
The question is, shouldn't we prevent sample weak passwords in production environment? I think we discussed this somewhere, about how to notify provider of the sample buyer user account. e.g. email/internal messaging.
But I'm not sure we came to an agreement.
Let tests fail to get the complete list of outdated tests
| // activerecord.errors.models.user.attributes.password.weak | ||
| const STRONG_PASSWORD_MIN_SIZE = 15 | ||
| const RE_STRONG_PASSWORD = new RegExp(`^[ -~]{${STRONG_PASSWORD_MIN_SIZE},}$`) | ||
| const STRONG_PASSWORD_FAIL_MSG = `Password must be at least ${STRONG_PASSWORD_MIN_SIZE} characters long, and contain only valid characters.` |
There was a problem hiding this comment.
should we just check the size and not a RE?
There was a problem hiding this comment.
I'm checking this and I think we must in fact validate only the size. I took this RE from:
3. Verifiers and CSPs SHOULD accept all printing ASCII [RFC20] characters and the
space character in passwords.
But there's in fact a point #4 in the doc:
4. Verifiers and CSPs SHOULD accept Unicode [ISO/ISC 10646] characters in
passwords. Each Unicode code point SHALL be counted as a single character when
evaluating password length.
So the RE was wrong. I explain this in more detail on #4195 (comment).
d35585c to
0d23aac
Compare
| has_secure_password validations: false | ||
|
|
||
| validates_presence_of :password, if: :password_required? | ||
| before_validation :normalize_password, if: :validate_password? |
There was a problem hiding this comment.
We must accept unicode characters in passwords. And ensure they are the same size no matter how they are represented.
We have a problem here: there are different ways to represent the same string in unicode:
https://www.honeybadger.io/blog/ruby-unicode-normalization/
W3c recommends composed (NFC): https://www.w3.org/International/questions/qa-html-css-normalization.en
Linux and windows send unicodes in that format, but MAC uses decomposed (NFD). In practice, that means a user could signup and set a password in MAC, and then won't be able to login from Linux.
In Rails 7.1 we have the normalizes method which makes the things much easier, but that doesn't work on virtual attributes like password and password_confirmation. For that, we'll have to wait till Rails 8.1:
https://blog.saeloun.com/2025/03/10/rails-introduces-active-model-normalization/
https://github.com/rails/rails/blob/v8.1.0/activemodel/CHANGELOG.md
There was a problem hiding this comment.
As a collateral effect of this change, those users using MAC and having non-ASCII characters in their passwords will have to reset it.
There was a problem hiding this comment.
I'm just wondering, is this only Safari or any browser running on mac? Does it affect iPhones or only macs?
Either way, I don't think we can help them but good to know for the release notes.
There was a problem hiding this comment.
btw, did we allow multi-byte UTF chars previously?
There was a problem hiding this comment.
According to your article, they recommend using NFKC for usernames, but maybe for passwords it is irrelevant as long as it is consistent 🤔
There was a problem hiding this comment.
Wrong link, I edited it, the correct one is from W3C: https://www.w3.org/International/questions/qa-html-css-normalization.en
The best way to ensure that these match is to use one particular Unicode
normalization form for all syntactic constructs, and as the default for
all authored content. It doesn't really matter whether you use the NFC or
NFD normalization form, it's more important that you are consistent. NFC
is, however, a good recommendation because this is what many (but not all)
keyboards tend to produce. (Most keyboards for European languages output
text in NFC already, but this is less likely to be the case if dealing with
many non-European languages.)
We don't recommend using any of the K forms in this way.
There was a problem hiding this comment.
btw, did we allow multi-byte UTF chars previously?
I think we did, and as long as the user logged in using the same device they used when creating the password, that would cause no issues for them.
There was a problem hiding this comment.
I'm just wondering, is this only Safari or any browser running on mac? Does it affect iPhones or only macs? Either way, I don't think we can help them but good to know for the release notes.
I took Claude's word for this, now I verified it and in fact it was a hallucination.
Browsers send whatever the user types in the input. Apparently this is normally NFC for most cases, but there could be corner cases like writing from a in-screen keyboard, or copy-pasting from a document in NFD, that would lead in the browser sending NFD.
| def authenticate(password) | ||
| authenticate_without_normalization(password&.unicode_normalize(:nfc)) | ||
| end | ||
| alias_method :authenticated?, :authenticate |
There was a problem hiding this comment.
This is needed so authenticate? also normalizes before comparing the pass to DB
| assert_select 'input[name="user[password_confirmation]"]' | ||
| end | ||
|
|
||
| test 'edit page shows password fields for SSO user without password' do |
There was a problem hiding this comment.
wasn't there some scenario where we should NOT show the fields?
| @provider_account.payment_gateway_options[:merchant_id] = "my-payment-gw-mid" | ||
| @provider_account.payment_gateway_options[:public_key] = "AnY-pUbLiC-kEy" | ||
| @provider_account.payment_gateway_options[:private_key] = "a1b2c3d4e5" | ||
| login_with @buyer_account.admins.first.username, 'supersecret' |
There was a problem hiding this comment.
was this login line intentionally removed?
| } | ||
| def password_required? | ||
| @user.password_required? | ||
| @user.signup.by_user? |
| And I fill in "Current password" with "superSecret1234#" | ||
| And I press "Update Details" | ||
| Then field "New password" has inline error "is too short (minimum is 6 characters)" | ||
| Then I should see the error that the password is too weak No newline at end of file |
There was a problem hiding this comment.
no new line char at end ?!?!?!??!?
| force_ssl: false | ||
| report_traffic: false | ||
| secure_cookie: false | ||
| strong_passwords_disabled: true |
|
|
||
| def using_password? | ||
| password_digest.present? | ||
| password_digest_in_database.present? |
There was a problem hiding this comment.
wait, if password_digest was set but not yet in the database, shouldn't we still return true here?
| self.lost_password_token_generated_at = Time.current | ||
|
|
||
| token | ||
| token if save(validate: false) |
There was a problem hiding this comment.
Do we still need validate: false now that we validate on password change only?
|
|
||
| def validate_strong_password? | ||
| return false if Rails.configuration.three_scale.strong_passwords_disabled | ||
| return false if signup.sample_data? |
There was a problem hiding this comment.
| return false if signup.sample_data? | |
| return false if signup.sample_data? && !Rails.env.production? |
Follow up on previous question about sample data ending up in production, I would imagine the logic to something like this.
| self.lost_password_token = nil | ||
| end | ||
|
|
||
| # To avoid all this logic, from Rails 8.1+ we can use |
There was a problem hiding this comment.
| # To avoid all this logic, from Rails 8.1+ we can use | |
| raise "FIXME" if Gem::Version.new(Rails.version) >= Gem::Version.new("8.1") | |
| # To avoid all this logic, from Rails 8.1+ we can use |
| \z | ||
| /x | ||
| STRONG_PASSWORD_FAIL_MSG = "Password must be at least 8 characters long, and contain both upper and lowercase letters, a digit and one special character of #{SPECIAL_CHARACTERS}.".freeze | ||
| STRONG_PASSWORD_MIN_SIZE = 15 |
There was a problem hiding this comment.
it might be a good idea in case some customers want to apply higher level of security, as you wish.
| STRONG_PASSWORD_MIN_SIZE = 15 | |
| STRONG_PASSWORD_MIN_SIZE = ENV["THREESCALE_PASSWORD_MIN_SIZE", 15] |

Note
This PR includes all changes from #4194. To make it easier to review, jlledom#3 includes only the actual diff to make strong passwords mandatory.
What this PR does / why we need it:
Currently, a provider can enforce strong password for the developer portal, and weak passwords are accepted by default. About admin portal, there's no option to enable strong passwords, weak passwords are always accepted.
I think both situations make no sense. I don't think it's acceptable to allow users decide whether they enforce strong passwords or not, as long as strong passwords are possible, that should be the default. And same thing about admin portal.
After a discussion via slack, we agreed on this terms:
settings.ymlto allow weak passwords for usstrong_passwords_disabled: truetosettings.yml. If not set at all, strong passwords are enabled, so clients don't have to change anything on their side.db:seedshould accept weak passwordsstrong_passwords_disabledtotruebefore running the task:smaple_data. Sample data accepts weak passwords, no matter the setting.This affects multiple screens, but also API endpoints, this is the complete list:
Additionally, I also added some behavioral changes to the UI. To solve bugs or behaviors I think are incorrect. This is the summarty Claude generated:
Screens/Forms Modified
app/javascript/src/Login/utils/validations.ts)length: { minimum: 6 }to regex pattern matching backend rulesSTRONG_PASSWORD_*constants mirroring backend.app/views/buyers/accounts/new.html.erb)type: 'password'to password field. Previously the field was showing password as clear text.app/views/buyers/users/edit.html.erb)required: trueto `required:app/views/provider/admin/account/users/_form.html.erb)required: truetorequired: false. Password is now optional when editing a provider user.app/views/provider/admin/user/personal_details/edit.html.slim)1. Removed the toast/alert prompting SSO users to set password via reset form
2. Password field now always visible (not conditional on
using_password)3. Password field changed to
required: false4. Added
type: 'password'to hide password input5. "Current password" section only shown when
using_password?is true (users with existing password must confirm it)config/locales/en.yml)set_password_htmltranslation (toast was removed).Summary of behavioral changes
Strong password validation in React - Frontend now enforces the same 16+ character ASCII-printable password rules as the backend.
Password fields show as masked - Fixed forms that were accidentally displaying passwords as plain text.
Password fields are optional on edit - When editing existing users (buyer or provider), password is no longer required. You can update other fields without changing the password.
Password field always visible - On personal details page, the password field is now always shown regardless of how the user signed up (SSO, password, etc.). Any user can set/change their password.
Current password only required when applicable - The "Current password" confirmation field only appears when the user already has a password set (
using_password?). New SSO users setting a password for the first timedon't need to provide a current password.
Removed SSO password reset toast - SSO users no longer see the banner telling them to use the password reset form. They can now set their password directly from the personal details form.
Works with enforce SSO - Passwords can still be set/changed even when enforce SSO is enabled.
I know this is a though one. I recommend to review the commits one by one. Also, I'll add some comments to provider further explanations.
Which issue(s) this PR fixes
https://issues.redhat.com/browse/THREESCALE-8916
https://issues.redhat.com/browse/THREESCALE-11548
Verification steps
You can go through any (ideally all) screens above an try to set a weak password. Also tests should pass.
List of added tests
This is the complete list of tests added in this PR. To cover all the new behaviour explained above:
test/unit/authentication/by_password_test.rbvalidate_password?,validate_strong_password?, andusing_password?methods. Covers scenarios for different signup types (new_signup, minimal, sample_data, machine), password change detection, and strong password validation bypass for sample_data users.test/unit/liquid/drops/user_drop_test.rbusing_password?andpassword_required?Liquid drop methods, verifying correct behavior for SSO users, by_user signups, and sample_data signups.test/unit/user/signup_type_test.rbsample_data?,machine?, andby_user?methods to verify sample_data signup type is correctly identified and classified as a machine signup.test/unit/user/states_test.rbActivateOnMinimalOrSampleDataTestclass with tests for auto-activation logic, covering minimal and sample_data signups with/without password and approval requirements.test/unit/logic/provider_signup_test.rbsample_datasignup type, bypasses strong password validation, and is auto-activated.test/integration/developer_portal/signup_test.rbStrongPasswordsTestclass testing developer portal signup with strong password validation enabled/disabled.test/integration/provider/signups_controller_integration_test.rbSignupsControllerStrongPasswordsTestclass testing admin portal provider signup with strong password validation enabled/disabled.test/integration/provider/admin/account/users_controller_test.rbtest/functional/buyers/users_controller_test.rbEditPagePasswordFieldsTestclass verifying password fields are always visible on buyer user edit page, including for SSO users.test/functional/provider/admin/user/personal_details_controller_test.rbSSOUserWithoutPasswordTest,UserWithPasswordTest, andEnforceSSOEnabledTestclasses testing personal details page behavior for SSO users, current password requirements, and enforce SSO scenarios.test/functional/partners/providers_controller_test.rbtest/functional/partners/users_controller_test.rbtest/integration/admin/api/buyers_users_controller_test.rbtest/integration/user-management-api/users_test.rbPUT /admin/api/users/{id}endpoint.