Skip to content

THREESCALE-8916: Make strong passwords mandatory#4195

Open
jlledom wants to merge 19 commits into3scale:masterfrom
jlledom:THREESCALE-8916-admin-strong-paswords
Open

THREESCALE-8916: Make strong passwords mandatory#4195
jlledom wants to merge 19 commits into3scale:masterfrom
jlledom:THREESCALE-8916-admin-strong-paswords

Conversation

@jlledom
Copy link
Contributor

@jlledom jlledom commented Jan 9, 2026

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:

  1. Enforce NIST policies
  2. Enforce for all users with password, not matter how did they sign up
    • Currently, users created automatically are not affected by the strong passwords setting. This is problematic because master, providers and buyers admin users are created this way, so those are never enforced to have strong passwords no matter the setting.
  3. Unify UI and API behavior
    • API endpoints don't consistently check for strong passwords, after this PR, all endpoints ask for strong passwords, also all UI forms.
  4. Remove the strong passwords setting
    • It will continue in db but completely ignored, since its mandatory now.
  5. Add a new setting on settings.yml to allow weak passwords for us
    • For develop and test purposes, we can add strong_passwords_disabled: true to settings.yml. If not set at all, strong passwords are enabled, so clients don't have to change anything on their side.
  6. Fix javascript password validation in forms, it's very inconsistent now
  7. db:seed should accept weak passwords
    • This is possible without changes in the code, just ensure to set strong_passwords_disabled to true before running the task
  8. John Doe: allow 123456
    • I added a new signup type called :smaple_data. Sample data accepts weak passwords, no matter the setting.

This affects multiple screens, but also API endpoints, this is the complete list:

  • All scenarios with a password
    • Admin portal
      • UI
        • User invitation signup form
        • Personal details
        • Edit user
        • Forgot password
        • Create new buyer
        • Edit buyer user
      • API
        • POST /master/api/providers.xml
        • POST /admin/api/users.xml
        • PUT /admin/api/users/{id}.xml
        • POST /partners/providers
        • POST /partners/providers/{id}/users
    • Developer portal
      • UI
        • New buyer signup form
        • Existing buyer user invitation signup form
        • Personal details
        • Edit user
        • Forgot password
      • API
        • POST /admin/api/signup.xml
        • POST /admin/api/accounts/{id}/users.xml
        • PUT /admin/api/accounts/{id}/users/{id}.xml

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

Screen/File Location Changes
React Password Validation (app/javascript/src/Login/utils/validations.ts) Frontend JS Added strong password validation to React forms. Changed from length: { minimum: 6 } to regex pattern matching backend rules
(16+ chars, ASCII printable only). Added STRONG_PASSWORD_* constants mirroring backend.
Create Buyer Account (app/views/buyers/accounts/new.html.erb) Admin Portal → Audience → Accounts → Create Added type: 'password' to password field. Previously the field was showing password as clear text.
Edit Buyer User (app/views/buyers/users/edit.html.erb) Admin Portal → Audience → Accounts → [Account] → Users → [User] → Edit Changed password and password_confirmation fields from required: true to `required:
false`. Password is now optional when editing a buyer user.
Edit Provider User (app/views/provider/admin/account/users/_form.html.erb) Admin Portal → Account Settings → Users → [User] → Edit Changed password and password_confirmation fields from required: true to
required: false. Password is now optional when editing a provider user.
Edit Personal Details (app/views/provider/admin/user/personal_details/edit.html.slim) Admin Portal → Account Settings → Personal Details Multiple changes:
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: false
4. Added type: 'password' to hide password input
5. "Current password" section only shown when using_password? is true (users with existing password must confirm it)
Locales (config/locales/en.yml) N/A Fixed YAML syntax for error message. Removed set_password_html translation (toast was removed).

Summary of behavioral changes

  1. Strong password validation in React - Frontend now enforces the same 16+ character ASCII-printable password rules as the backend.

  2. Password fields show as masked - Fixed forms that were accidentally displaying passwords as plain text.

  3. 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.

  4. 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.

  5. 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 time
    don't need to provide a current password.

  6. 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.

  7. 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:

File Description
test/unit/authentication/by_password_test.rb Added tests for validate_password?, validate_strong_password?, and using_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.rb Added tests for using_password? and password_required? Liquid drop methods, verifying correct behavior for SSO users, by_user signups, and sample_data signups.
test/unit/user/signup_type_test.rb Added tests for sample_data?, machine?, and by_user? methods to verify sample_data signup type is correctly identified and classified as a machine signup.
test/unit/user/states_test.rb Added ActivateOnMinimalOrSampleDataTest class with tests for auto-activation logic, covering minimal and sample_data signups with/without password and approval requirements.
test/unit/logic/provider_signup_test.rb New test file verifying John Doe (sample data user) is created with sample_data signup type, bypasses strong password validation, and is auto-activated.
test/integration/developer_portal/signup_test.rb Added StrongPasswordsTest class testing developer portal signup with strong password validation enabled/disabled.
test/integration/provider/signups_controller_integration_test.rb Added SignupsControllerStrongPasswordsTest class testing admin portal provider signup with strong password validation enabled/disabled.
test/integration/provider/admin/account/users_controller_test.rb Added tests verifying password fields are always visible on provider user edit page, including for SSO users without password.
test/functional/buyers/users_controller_test.rb Added EditPagePasswordFieldsTest class verifying password fields are always visible on buyer user edit page, including for SSO users.
test/functional/provider/admin/user/personal_details_controller_test.rb Added SSOUserWithoutPasswordTest, UserWithPasswordTest, and EnforceSSOEnabledTest classes testing personal details page behavior for SSO users, current password requirements, and enforce SSO scenarios.
test/functional/partners/providers_controller_test.rb Added tests for creating providers without password and strong password validation on Partners API.
test/functional/partners/users_controller_test.rb Added tests for creating users with/without password, error handling (422 status), and strong password validation on Partners API.
test/integration/admin/api/buyers_users_controller_test.rb Added tests for updating buyer users with password via API and strong password validation.
test/integration/user-management-api/users_test.rb Added tests for strong password validation when updating provider users via PUT /admin/api/users/{id} endpoint.

@jlledom jlledom changed the title Threescale 8916 admin strong paswords THREESCALE-8916: Make strong passwords mandatory Jan 9, 2026
@jlledom jlledom force-pushed the THREESCALE-8916-admin-strong-paswords branch from da18ea7 to 5b00c7f Compare January 9, 2026 14:14
Comment on lines 8 to 17
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
'$'
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

@akostadinov akostadinov Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel uneasy about this whole file. What is it doing? Why is it wrapping? What was super?


def password_required?
signup.by_user? && super
(signup.by_user? || using_password?) && super
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you explain why do we need this change?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we rename this method? What is super? I wonder if we should separate usage of password on signup and for validation.

Copy link
Contributor

@akostadinov akostadinov Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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

@mayorova
Copy link
Contributor

To be honest, I find it quite annoying to not being able to set a "weak" password, especially it's uncomfortable in the development environment, where I'm not interested in security.
Also, customers can also have their test accounts, and it could be annoying to be obliged to use a 16-characters password... 😒

So, ideally, I'd prefer to keep the optional "strong password" setting. Whoever cares about security can enable it 😬

As for the admin portal,

About admin portal, there's no option to enable strong passwords, weak passwords are always accepted.

I think you can enable strong password on the Master portal, and this setting will be applied to the admin portal passwords. Unless this doesn't work as expected:
image

@jlledom jlledom force-pushed the THREESCALE-8916-admin-strong-paswords branch from 33fe198 to 25f4a95 Compare January 13, 2026 09:43
@jlledom jlledom self-assigned this Jan 14, 2026
@jlledom jlledom force-pushed the THREESCALE-8916-admin-strong-paswords branch 3 times, most recently from 101f7d4 to 7c97a77 Compare February 5, 2026 17:09
- 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.
@jlledom jlledom force-pushed the THREESCALE-8916-admin-strong-paswords branch 2 times, most recently from f864657 to 891e8cb Compare February 6, 2026 12:39
jlledom and others added 4 commits February 6, 2026 13:51
This is to exclude John Doe from strong passwords validation

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@jlledom jlledom force-pushed the THREESCALE-8916-admin-strong-paswords branch from 891e8cb to 35acc2b Compare February 6, 2026 13:11
authorize! :update, user

user.update_with_flattened_attributes(flat_params)
user.update_with_flattened_attributes(flat_params, as: current_user.try(:role))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

porta/app/models/user.rb

Lines 112 to 116 in 986e1a3

attr_accessible :title, :username, :email, :first_name, :last_name,
:conditions, :cas_identifier, :open_id, :service_conditions,
:job_role, :extra_fields, as: %i[default member admin]
attr_accessible :member_permission_service_ids, :member_permission_ids, as: %i[admin]

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 🤷

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: 59aa2f7

{
signup_type: partner.signup_type,
password: permitted_params[:password].presence || SecureRandom.hex,
password: permitted_params[:password].presence,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Contributor Author

@jlledom jlledom Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same, no password required.

Comment on lines 3 to 9
// 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.`

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment on lines 39 to 48
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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In order to exclude "John Doe" from strong password requirement, I added a new signup type :sample_data to identify it.

Comment on lines +78 to +79
def activate_on_minimal_or_sample_data?
(minimal_signup? || signup.sample_data?) && password.present? && !account.try!(:bought_account_plan).try!(:approval_required?)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method determines whether the new user must be automatically activated or not. I added the :sample_data case to it. Sample user must be activated by default.

Comment on lines -347 to -353
def provider_requires_strong_passwords?
# use fields definitons source (instance variable) as backup when creating new record
# and there is no provider account (its still new record and not set through association.build)
if source = fields_definitions_source_root
source.settings.strong_passwords_enabled?
end
end
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead code AFAIK

<%= form.fields_for [:user, @user ] do |user| %>
<%= user.user_defined_form %>
<%= user.input :password, as: :patternfly_input, required: true %>
<%= user.input :password, as: :patternfly_input, input_html: { type: 'password' }, required: true %>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Password input was in clear text

Comment on lines +16 to +17
<%= form.input :password, required: false, input_html: { autocomplete: 'off' } %>
<%= form.input :password_confirmation, required: false, input_html: { autocomplete: 'off' } %>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was wrong, there's no scenario when it's required to change a password in this form.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In fact, it was a "fake" requirement, the form submit was working without provided values.

Comment on lines +16 to +21
= form.input :password, as: :patternfly_input,
label: t('.new_password_label'),
required: false,
input_html: { value: '', type: 'password', required: false }

- if using_password
- if current_user.using_password?
Copy link
Contributor Author

@jlledom jlledom Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Password change inputs are now always visible. However, the current_password input is only visible if the user already has a password in the DB.

paths = [
'''test/unit/authentication/by_password_test.rb''',
'''test/integration/admin/api/buyers_users_controller_test.rb'''
]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had to add this so our password leak system allows me to commit.

@jlledom jlledom marked this pull request as ready for review February 9, 2026 07:30
STRONG_PASSWORD_MIN_SIZE = 16
# All printable characters in ASCII, from 'space' (32) to ~ (126)
# at least STRONG_PASSWORD_MIN_SIZE characters
RE_STRONG_PASSWORD = /\A[ -~]{#{STRONG_PASSWORD_MIN_SIZE},64}\z/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we limit the character ranges for passwords, I don't recall reading such nist requirement.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I misunderstood this sentence from NIST:

Verifiers and CSPs SHOULD permit a maximum password length of at least 64
characters.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: 02b270b

# All printable characters in ASCII, from 'space' (32) to ~ (126)
# at least STRONG_PASSWORD_MIN_SIZE characters
RE_STRONG_PASSWORD = /\A[ -~]{#{STRONG_PASSWORD_MIN_SIZE},64}\z/
STRONG_PASSWORD_FAIL_MSG = "Password must be at least #{STRONG_PASSWORD_MIN_SIZE} characters long, and contain only valid characters.".freeze
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i18n 😬

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: 485d40f

(For backend, I think we don't use I18N for frontend)

if: -> { password_required? && provider_requires_strong_passwords? } }
validates :password, length: { minimum: 6, allow_blank: true,
if: -> { password_required? && !provider_requires_strong_passwords? } }
if: -> { validate_strong_password? } }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't get it, I thought we will validate passwords according to nist requirements unconditionally which IIRC was 15 characters min. But STRONG_PASSWORD_MIN_SIZE = 16 and it is only validated when configured by the provider. I'm not sure we are on the same page.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought we will validate passwords according to nist requirements unconditionally which IIRC was 15 characters min. But STRONG_PASSWORD_MIN_SIZE = 16

I think I took 16 before your comment about NIST. I can make it 15, NP.

and it is only validated when configured by the provider

I don't get this. What do you mean by configured by the provider? this PR is about making strong password mandatory, not when configured by the provider, we already have that in production.

There only exception to strong passwords validation now are:

  • When sample data (John Doe)
  • When we set strong_passwords_disabled: true in settings.yml. Which is intended to be used only by us in development.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, maybe I misinterpret if: -> { validate_strong_password? } }

But then why do we need both - signup.sample_data? and Rails.configuration.three_scale.strong_passwords_disabled?

If we want, we can make strong passwords disabled by default in development mode. And not care about signup.sample_data?

Or are there other situations sample_data? should kick in?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we want, we can make strong passwords disabled by default in development mode

Yeah we could, but we would still need a way to enable it in development when we want, for instance, I need it enabled to actually develop the feature. Adding a exception in the code is worse than a setting IMO, basically because I would need to make changes in the code every time I need to switch it on an off, and have it lingering in the git cache, maybe even committing those changes by accident.

And not care about signup.sample_data? Or are there other situations sample_data? should kick in?

Sample data is basically for John Doe, and John Doe is created automatically in buyer signup in all environments, also production.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Min pass size to 15: b986bea

def password_required?
signup.by_user? && (password_digest.blank? || password_digest_changed?)
def validate_password?
password_digest_changed? || (signup.by_user? && password_digest.blank?)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest ot have this. In this case the issue described #4194 (comment) is avoided and we don't need to use update_columns

Suggested change
password_digest_changed? || (signup.by_user? && password_digest.blank?)
return unless will_save_change_to_password_digest? || new_record?
password_digest_changed? || (signup.by_user? && password_digest.blank?)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no need to apply this because that comment you linked was about providing a way for non-migrated users to create a new password. This is already working in this branch, so no changes required.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue is that in the linked PR, updating fields was switched to update_columns which IMO should be used when actually necessary as it skips all validations and callbacks. I don't see any specific other validations and callbacks that should run at that point. But the whole idea is to use update_columns as an exception, because you never know about possible side effects.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But this method has nothing to do with that. you mean generate_lost_password_token. I can use save(validate: false) there.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: 9f64588

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is not just recovery token. One will be unable even to suspend a user with old-style password.

[3] pry(main)> u.suspend!
  TRANSACTION (0.4ms)  BEGIN
  TRANSACTION (0.2ms)  ROLLBACK
StateMachines::InvalidTransition: Cannot transition state via :suspend from :active (Reason(s): Password can't be blank, Password Password must be at least 15 characters long, and contain only valid characters.)
from /home/akostadi/.asdf/installs/ruby/3.1.7/lib/ruby/gems/3.1.0/gems/state_machines-0.5.0/lib/state_machines/event.rb:224:in `block in add_actions'

I think I found the issue. Originally we had this:

  def validate_password?
    password_required? && password_changed?
  end

Now we have

      password_digest_changed? || (signup.by_user? && password_digest.blank?)

It is not even clear to me why do we have this logic. A comment on top of the method explaining the intention can be very useful.
I'm not sure what the idea behind (signup.by_user? && password_digest.blank?) is.

Basically in the past we didn't perform validation when password digest did not change. will_save_change_to_password_digest? is the more modern and unambiguous version of password_digest_changed?.

I think that we need to make sure password state does not prevent other operations over a user.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the use case for (signup.by_user? && password_digest.blank?) ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

About (signup.by_user? && password_digest.blank?), the scenario is the signup form, when you send it without a password, it should validate. This form is from the developer portal so clients can send anything from there.

Also, after my commit in this PR: fbd2a20, the API actually allows creating users without a password. If you also provide an open id, then signup.by_user? will be false and allow the user to not have a pass, but if you don't send neither open id or pass, this clause will prevent the user creation.

I tried with will_save_change_to_password_digest? all alone and it doesn't protect from those scenarios, so the additional clause is needed.

If you don't like it, I can do this:

  • Remove the clause
  • Use strong params to make password required in the signup form
  • Use strong params to make either password or open_id required in the partner endpoints
  • Add tests for those controllers.

IMO, it's more secure to rely on the model validation than in controller parameters.

About unmigrated users not being editable, I don't know, they are supposed to be migrated since 9 years ago. We don't have any unmigrated user in SaaS and it's very unlikely to have any onpremises. If such user would exist, we could tell the client to follow the reset password flow to make the user valid. I'm not sure it's worth to complicate the logic even more to fit this use case.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it is better to validate within the model.

The basic idea is that when password is not changed or this is not a new object, we would not validate the password.

Then in case password changed or this is a new object, we check whether password login is allowed and validate when allowed.

From what I read, we need to handle the signup case, where you say that initially the user is created without a password. In that case your check (signup.by_user? && password_digest.blank?) might be reasonable. Will it match only when user is first created and not later in the user object lifecycle?

btw, if by signup we create a nil password user object, maybe we can change this to create the user with some long random string instead.

Do we have any other edge cases to handle?


Given "{provider} is requiring strong passwords" do |provider|
provider.settings.update_attribute :strong_passwords_enabled, true
# When RAILS_ENV=test, strong passwords are disabled by default
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it makes sense to not disable strong passwords in tests, so by default all tests assume that strong tests are required (so, it tests production behavior more closely).

And only disable strong passwords for specific scenarios.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did that first. I enabled strong passwords by default in tests and made changes across all the test suite to fix the tens of tests currently using weak passwords. That affected tens of files. The PR counted more that 80+ changed files. At the end I reverted those commits because I thought this PR was already complicated enough with 50+ files to be reviewed.

I can do it again if you think it's better.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for being considerate! would be nice to do, ideally in a follow-up PR :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally think that doing it in another PR would be confusing 🙃

The test changes are already huge enough so that some people (me, for example) will not have enough courage to look through them 🫠

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I'll change it again. I'll set superSecret1234# as the password for all tests now

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the way, I undrestand that only the password length is enforced now? So, it can also be all letters or all numbers? Not saying that's what you need to use for tests, just curious.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we are following this: https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-63B-4.pdf section 3.1.1.2.

Point 5:

Verifiers and CSPs SHALL NOT impose other composition rules (e.g., requiring
mixtures of different character types) for passwords.

I can guess the reason: password length is what matters, the longer, the harder to brute-force it. If we required the password to have say uppercase,lowercase, numbers and symbols, we would make it easier to brute-force since we would be providing info about the password.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because studies have shown special characters are hard to remember so better rely on length. e.g. easier to remember one more word than where exactly you have replaced i with 1. And where was the capital letter. While one additional word would provide more randomness. Also when passwords are easier to remember, people are less likely to put them on a sticky note on their monitor. Similar logic behind non-expiring passwords.

But either way, I don't think we should restrict to characters. IMO just check for length, not which characters were used.

@user.save!

render json: {id: @user.id, success: true}
rescue StandardError
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why rescue instead of rely on @user.save return value?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the previous call to @user.activate! can raise an exception, which was uncaught.

@mayorova
Copy link
Contributor

This needs to be coordinated with 3scale Operator. Currently, the operator generates some random passwords, which are currently 8 characters long: https://github.com/3scale/3scale-operator/blob/b99f57856197150a3eca1b6ae8f610c8ff9a7107/pkg/3scale/amp/component/system_options.go#L165-L175

// 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.`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we just check the size and not a RE?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants