From 7fac9f8b2ff9a972448ecdcaa290f0b0cfb3df32 Mon Sep 17 00:00:00 2001 From: xunyeting <693894305@qq.com> Date: Tue, 7 Jul 2026 06:27:22 +0800 Subject: [PATCH] [JENKINS-69789] Add password complexity rule extension point (#26774) * [JENKINS-69789] Add configurable password complexity policy to HudsonPrivateSecurityRealm Jenkins' built-in user database currently has no password complexity requirements beyond non-empty and encoder limits. This makes it difficult to meet enterprise security policies. Add configurable password policy options (minimum length, require uppercase/lowercase/digit/special character) via @DataBoundSetter fields on HudsonPrivateSecurityRealm. The policy is enforced on both user self-registration and admin password changes. All options default to off for backward compatibility. Closes #18323 Co-Authored-By: Claude Opus 4.6 * [JENKINS-69789] Add password complexity rule extension point Refactor password complexity validation from hardcoded fields to an ExtensionPoint architecture. Add PasswordComplexityRule as a new extension point that allows plugins to contribute custom password validation strategies. Include DefaultPasswordComplexityRule as a built-in implementation with configurable minimum length and character type requirements (uppercase, lowercase, digit, special character). The rule is optional and configured via the Security settings UI using a dropdown selector. When no rule is selected, no extra password requirements are enforced, preserving backward compatibility. Closes #18323 Co-Authored-By: Claude Opus 4.6 * Fix Spotless import ordering violation in Functions.java Co-Authored-By: Claude Opus 4.6 * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Add tests for password change flow with complexity rule Co-Authored-By: Claude Opus 4.6 * Refactor PasswordComplexityRule per review feedback - Remove PasswordComplexityRuleDescriptor, use Descriptor - Remove getDescriptor() override from PasswordComplexityRule - Change validate() to void, throw PasswordComplexityException - Add NonePasswordComplexityRule to avoid null checks - Use dropdownDescriptorSelector in config.jelly - Remove getPasswordComplexityRuleDescriptors() from Functions.java - Change default minimum length from 0 to 8 - Add readResolve() for backward compatibility - Update tests for new validate() signature Co-Authored-By: Claude Opus 4.6 * Remove @Restricted from DataBoundSetter to fix Stapler binding Co-Authored-By: Claude Opus 4.6 * Fix Spotless import ordering in HudsonPrivateSecurityRealmTest Co-Authored-By: Claude Opus 4.6 * Fix ATH test failures by setting a default for password complexity rule When using for passwordComplexityRule without a default attribute, the UI implicitly selects the first descriptor alphabetically (DefaultPasswordComplexityRule) for new instances. This caused JenkinsDatabaseSecurityRealmTest.login_and_logout in the Acceptance Test Harness to unexpectedly enforce password complexity rules and fail when testing with simple passwords. This commit explicitly sets the default passwordComplexityRule to NonePasswordComplexityRule via the default attribute in the Jelly UI, preserving the expected default behavior and fixing the ATH failures. * Refactor password complexity tests per review feedback - Move pure rule validation tests to core module as lightweight unit tests (no JenkinsRule needed) in DefaultPasswordComplexityRuleTest - Add assertions on exception messages to verify user-actionable content - Add tests for each individual character type requirement - Add test verifying multiple violations are reported together - Remove unused assertDoesNotThrow import from integration test Co-Authored-By: Claude Opus 4.6 * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Update core/src/main/resources/hudson/security/DefaultPasswordComplexityRule/config.jelly Co-authored-by: James Nord * Rename DefaultPasswordComplexityRule to BasicPasswordComplexityRule Avoids confusion with "default" implying it is the default selection (the actual default is NonePasswordComplexityRule). Co-Authored-By: Claude Opus 4.6 * Add real-time password validation on password change page - Add doCheckPassword() to Details.DescriptorImpl for AJAX validation - Change password inputs to f:password tags to enable auto-validation - Handles Protector-encrypted values gracefully Co-Authored-By: Claude Opus 4.6 * Revert real-time password validation on change page The f:password tag renders as hidden input, breaking existing tests that expect HtmlPasswordInput. Real-time validation on the password change page requires a different approach and will be addressed in a follow-up PR. Co-Authored-By: Claude Opus 4.6 * Fixed test case failure * test: Address PR review by using allOf for multiple assertions * feat: Introduce PasswordComplexityRuleDescriptor base type * fix: Revert all() return type to avoid generic unchecked assignment * fix: Add @POST to doCheckPassword and fix Spotless formatting * fix: Replace deprecated getCheckUrl with toStemUrl to fix page nesting The deprecated getCheckUrl() returns a JavaScript expression that evaluates to a URL without a query string. When FormChecker.sendRequest processes a POST request with no '?' in the URL, url.indexOf('?') returns -1, causing url.substring(0, -1) to produce an empty string. This makes fetch('') request the current page, whose full HTML response gets inserted into the validation-error-area, triggering Behaviour.applySubtree on the new content and causing infinite page nesting on /user/{username}/security. Switch to the modern getCheckMethod().toStemUrl() which returns a plain URL, and add checkDependsOn="" so the frontend takes the modern code path that correctly appends the field value as a query parameter. Co-Authored-By: Claude Opus 4.6 * test: Add tests for doCheckPassword and null rule fallback - Test doCheckPassword returns error for weak password - Test doCheckPassword returns ok for strong password - Test doCheckPassword returns ok when no rule configured - Test doCheckPassword returns ok for empty password - Test setPasswordComplexityRule(null) falls back to NoneRule Co-Authored-By: Claude Opus 4.6 * fix: Move null rule fallback test to integration test module The setNullPasswordComplexityRuleFallsBackToNone test creates a HudsonPrivateSecurityRealm instance which requires Jenkins runtime. Move it from core unit tests to integration tests where JenkinsRule provides the necessary environment. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: James Nord --- .../security/BasicPasswordComplexityRule.java | 113 ++++++++++++++ .../security/HudsonPrivateSecurityRealm.java | 77 +++++++++ .../security/NonePasswordComplexityRule.java | 56 +++++++ .../security/PasswordComplexityException.java | 37 +++++ .../security/PasswordComplexityRule.java | 65 ++++++++ .../PasswordComplexityRuleDescriptor.java | 35 +++++ .../BasicPasswordComplexityRule/config.jelly | 18 +++ .../Details/config.jelly | 4 +- .../HudsonPrivateSecurityRealm/config.jelly | 1 + .../hudson/security/Messages.properties | 8 + .../BasicPasswordComplexityRuleTest.java | 93 +++++++++++ .../HudsonPrivateSecurityRealmTest.java | 147 ++++++++++++++++++ 12 files changed, 652 insertions(+), 2 deletions(-) create mode 100644 core/src/main/java/hudson/security/BasicPasswordComplexityRule.java create mode 100644 core/src/main/java/hudson/security/NonePasswordComplexityRule.java create mode 100644 core/src/main/java/hudson/security/PasswordComplexityException.java create mode 100644 core/src/main/java/hudson/security/PasswordComplexityRule.java create mode 100644 core/src/main/java/hudson/security/PasswordComplexityRuleDescriptor.java create mode 100644 core/src/main/resources/hudson/security/BasicPasswordComplexityRule/config.jelly create mode 100644 core/src/test/java/hudson/security/BasicPasswordComplexityRuleTest.java diff --git a/core/src/main/java/hudson/security/BasicPasswordComplexityRule.java b/core/src/main/java/hudson/security/BasicPasswordComplexityRule.java new file mode 100644 index 0000000000..8ddba35f1f --- /dev/null +++ b/core/src/main/java/hudson/security/BasicPasswordComplexityRule.java @@ -0,0 +1,113 @@ +/* + * The MIT License + * + * Copyright (c) 2025, Jenkins project contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package hudson.security; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import java.util.ArrayList; +import java.util.List; +import org.jenkinsci.Symbol; +import org.kohsuke.stapler.DataBoundConstructor; + +/** + * Built-in {@link PasswordComplexityRule} that checks minimum length + * and character type requirements. + * + * @since TODO + */ +public class BasicPasswordComplexityRule extends PasswordComplexityRule { + + private final int minimumLength; + private final boolean requireUppercase; + private final boolean requireLowercase; + private final boolean requireDigit; + private final boolean requireSpecialCharacter; + + @DataBoundConstructor + public BasicPasswordComplexityRule( + int minimumLength, + boolean requireUppercase, + boolean requireLowercase, + boolean requireDigit, + boolean requireSpecialCharacter) { + this.minimumLength = Math.max(0, minimumLength); + this.requireUppercase = requireUppercase; + this.requireLowercase = requireLowercase; + this.requireDigit = requireDigit; + this.requireSpecialCharacter = requireSpecialCharacter; + } + + public int getMinimumLength() { + return minimumLength; + } + + public boolean isRequireUppercase() { + return requireUppercase; + } + + public boolean isRequireLowercase() { + return requireLowercase; + } + + public boolean isRequireDigit() { + return requireDigit; + } + + public boolean isRequireSpecialCharacter() { + return requireSpecialCharacter; + } + + @Override + public void validate(@NonNull String password) throws PasswordComplexityException { + List errors = new ArrayList<>(); + if (minimumLength > 0 && password.length() < minimumLength) { + errors.add(Messages.HudsonPrivateSecurityRealm_CreateAccount_PasswordTooShort(minimumLength)); + } + if (requireUppercase && !password.matches(".*[A-Z].*")) { + errors.add(Messages.HudsonPrivateSecurityRealm_CreateAccount_PasswordRequiresUppercase()); + } + if (requireLowercase && !password.matches(".*[a-z].*")) { + errors.add(Messages.HudsonPrivateSecurityRealm_CreateAccount_PasswordRequiresLowercase()); + } + if (requireDigit && !password.matches(".*[0-9].*")) { + errors.add(Messages.HudsonPrivateSecurityRealm_CreateAccount_PasswordRequiresDigit()); + } + if (requireSpecialCharacter && !password.matches(".*[^a-zA-Z0-9].*")) { + errors.add(Messages.HudsonPrivateSecurityRealm_CreateAccount_PasswordRequiresSpecialCharacter()); + } + if (!errors.isEmpty()) { + throw new PasswordComplexityException(String.join(" ", errors)); + } + } + + @Extension @Symbol("basicPasswordComplexity") + public static final class DescriptorImpl extends PasswordComplexityRuleDescriptor { + @NonNull + @Override + public String getDisplayName() { + return Messages.BasicPasswordComplexityRule_DisplayName(); + } + } +} diff --git a/core/src/main/java/hudson/security/HudsonPrivateSecurityRealm.java b/core/src/main/java/hudson/security/HudsonPrivateSecurityRealm.java index f67cfd9fe6..f51bba32f9 100644 --- a/core/src/main/java/hudson/security/HudsonPrivateSecurityRealm.java +++ b/core/src/main/java/hudson/security/HudsonPrivateSecurityRealm.java @@ -84,6 +84,7 @@ import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.stapler.CompatibleFilter; import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; import org.kohsuke.stapler.ForwardToView; import org.kohsuke.stapler.HttpResponse; import org.kohsuke.stapler.HttpResponses; @@ -92,6 +93,7 @@ import org.kohsuke.stapler.Stapler; import org.kohsuke.stapler.StaplerRequest2; import org.kohsuke.stapler.StaplerResponse2; import org.kohsuke.stapler.interceptor.RequirePOST; +import org.kohsuke.stapler.verb.POST; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -135,6 +137,8 @@ public class HudsonPrivateSecurityRealm extends AbstractPasswordBasedSecurityRea */ private final boolean enableCaptcha; + private PasswordComplexityRule passwordComplexityRule = new NonePasswordComplexityRule(); + @Deprecated public HudsonPrivateSecurityRealm(boolean allowsSignup) { this(allowsSignup, false, (CaptchaSupport) null); @@ -175,6 +179,29 @@ public class HudsonPrivateSecurityRealm extends AbstractPasswordBasedSecurityRea return enableCaptcha; } + /** + * @since TODO + */ + @Restricted(NoExternalUse.class) + public PasswordComplexityRule getPasswordComplexityRule() { + return passwordComplexityRule; + } + + /** + * @since TODO + */ + @DataBoundSetter + public void setPasswordComplexityRule(PasswordComplexityRule passwordComplexityRule) { + this.passwordComplexityRule = passwordComplexityRule != null ? passwordComplexityRule : new NonePasswordComplexityRule(); + } + + private Object readResolve() { + if (passwordComplexityRule == null) { + passwordComplexityRule = new NonePasswordComplexityRule(); + } + return this; + } + /** * Computes if this Hudson has some user accounts configured. * @@ -461,6 +488,14 @@ public class HudsonPrivateSecurityRealm extends AbstractPasswordBasedSecurityRea si.errors.put("password1", ex.getMessage()); } + if (!si.errors.containsKey("password1") && si.password1 != null && !si.password1.isEmpty()) { + try { + passwordComplexityRule.validate(si.password1); + } catch (PasswordComplexityException e) { + si.errors.put("password1", e.getMessage()); + } + } + if (si.fullname == null || si.fullname.isEmpty()) { si.fullname = si.username; } @@ -858,6 +893,15 @@ public class HudsonPrivateSecurityRealm extends AbstractPasswordBasedSecurityRea throw new FormException(ex.getMessage(), "user.password"); } + SecurityRealm realm = Jenkins.get().getSecurityRealm(); + if (realm instanceof HudsonPrivateSecurityRealm hpsr) { + try { + hpsr.getPasswordComplexityRule().validate(pwd); + } catch (PasswordComplexityException e) { + throw new FormException(e.getMessage(), "user.password"); + } + } + User user = Util.getNearestAncestorOfTypeOrThrow(req, User.class); // the UserSeedProperty is not touched by the configure page UserSeedProperty userSeedProperty = user.getProperty(UserSeedProperty.class); @@ -868,6 +912,35 @@ public class HudsonPrivateSecurityRealm extends AbstractPasswordBasedSecurityRea return Details.fromPlainPassword(Util.fixNull(pwd)); } + @POST + public FormValidation doCheckPassword(@QueryParameter String value) { + String password = Util.fixEmpty(value); + if (password == null) { + return FormValidation.ok(); + } + + // will be null if it wasn't encrypted + String data = Protector.unprotect(password); + if (data != null) { + String prefix = Stapler.getCurrentRequest2().getSession().getId() + ':'; + if (data.startsWith(prefix)) { + // The password is not being changed + return FormValidation.ok(); + } + } + + SecurityRealm realm = Jenkins.get().getSecurityRealm(); + if (realm instanceof HudsonPrivateSecurityRealm hpsr) { + try { + hpsr.getPasswordComplexityRule().validate(password); + } catch (PasswordComplexityException e) { + return FormValidation.error(e.getMessage()); + } + } + + return FormValidation.ok(); + } + @Override public boolean isEnabled() { // this feature is only when HudsonPrivateSecurityRealm is enabled @@ -1157,6 +1230,10 @@ public class HudsonPrivateSecurityRealm extends AbstractPasswordBasedSecurityRea } return FormValidation.ok(); } + + public PasswordComplexityRule getDefaultPasswordComplexityRule() { + return new NonePasswordComplexityRule(); + } } private static final Filter CREATE_FIRST_USER_FILTER = new CompatibleFilter() { diff --git a/core/src/main/java/hudson/security/NonePasswordComplexityRule.java b/core/src/main/java/hudson/security/NonePasswordComplexityRule.java new file mode 100644 index 0000000000..ca09eec9cc --- /dev/null +++ b/core/src/main/java/hudson/security/NonePasswordComplexityRule.java @@ -0,0 +1,56 @@ +/* + * The MIT License + * + * Copyright (c) 2025, Jenkins project contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package hudson.security; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import org.jenkinsci.Symbol; +import org.kohsuke.stapler.DataBoundConstructor; + +/** + * A {@link PasswordComplexityRule} that accepts any password. + * + * @since TODO + */ +public class NonePasswordComplexityRule extends PasswordComplexityRule { + + @DataBoundConstructor + public NonePasswordComplexityRule() { + } + + @Override + public void validate(@NonNull String password) { + // No validation — any password is accepted + } + + @Extension @Symbol("nonePasswordComplexity") + public static final class DescriptorImpl extends PasswordComplexityRuleDescriptor { + @NonNull + @Override + public String getDisplayName() { + return Messages.NonePasswordComplexityRule_DisplayName(); + } + } +} diff --git a/core/src/main/java/hudson/security/PasswordComplexityException.java b/core/src/main/java/hudson/security/PasswordComplexityException.java new file mode 100644 index 0000000000..e83633270f --- /dev/null +++ b/core/src/main/java/hudson/security/PasswordComplexityException.java @@ -0,0 +1,37 @@ +/* + * The MIT License + * + * Copyright (c) 2025, Jenkins project contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package hudson.security; + +/** + * Thrown by {@link PasswordComplexityRule#validate(String)} when a password + * does not meet the configured complexity requirements. + * + * @since TODO + */ +public class PasswordComplexityException extends Exception { + public PasswordComplexityException(String message) { + super(message); + } +} diff --git a/core/src/main/java/hudson/security/PasswordComplexityRule.java b/core/src/main/java/hudson/security/PasswordComplexityRule.java new file mode 100644 index 0000000000..7b9c34d21e --- /dev/null +++ b/core/src/main/java/hudson/security/PasswordComplexityRule.java @@ -0,0 +1,65 @@ +/* + * The MIT License + * + * Copyright (c) 2025, Jenkins project contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package hudson.security; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.DescriptorExtensionList; +import hudson.ExtensionPoint; +import hudson.model.Describable; +import hudson.model.Descriptor; +import jenkins.model.Jenkins; + +/** + * Extension point for password complexity validation in {@link HudsonPrivateSecurityRealm}. + * + *

+ * Implementations define rules that passwords must satisfy during user registration + * and password changes. Each implementation can have an optional {@code config.jelly} + * to configure its validation rules. + * + * @since TODO + */ +public abstract class PasswordComplexityRule implements Describable, ExtensionPoint { + + /** + * Returns all the registered {@link PasswordComplexityRule} descriptors. + */ + public static DescriptorExtensionList> all() { + return Jenkins.get().getDescriptorList(PasswordComplexityRule.class); + } + + /** + * Validates the given password against this rule. + * + * @param password the password to validate + * @throws PasswordComplexityException if the password does not meet the requirements + */ + public abstract void validate(@NonNull String password) throws PasswordComplexityException; + + @Override + public PasswordComplexityRuleDescriptor getDescriptor() { + return (PasswordComplexityRuleDescriptor) Describable.super.getDescriptor(); + } +} diff --git a/core/src/main/java/hudson/security/PasswordComplexityRuleDescriptor.java b/core/src/main/java/hudson/security/PasswordComplexityRuleDescriptor.java new file mode 100644 index 0000000000..b9f69dd5b9 --- /dev/null +++ b/core/src/main/java/hudson/security/PasswordComplexityRuleDescriptor.java @@ -0,0 +1,35 @@ +/* + * The MIT License + * + * Copyright (c) 2025, Jenkins project contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package hudson.security; + +import hudson.model.Descriptor; + +/** + * {@link Descriptor} for {@link PasswordComplexityRule}. + * + * @since TODO + */ +public abstract class PasswordComplexityRuleDescriptor extends Descriptor { +} diff --git a/core/src/main/resources/hudson/security/BasicPasswordComplexityRule/config.jelly b/core/src/main/resources/hudson/security/BasicPasswordComplexityRule/config.jelly new file mode 100644 index 0000000000..8144ca6962 --- /dev/null +++ b/core/src/main/resources/hudson/security/BasicPasswordComplexityRule/config.jelly @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/core/src/main/resources/hudson/security/HudsonPrivateSecurityRealm/Details/config.jelly b/core/src/main/resources/hudson/security/HudsonPrivateSecurityRealm/Details/config.jelly index c53fd60db0..efdb8912c6 100644 --- a/core/src/main/resources/hudson/security/HudsonPrivateSecurityRealm/Details/config.jelly +++ b/core/src/main/resources/hudson/security/HudsonPrivateSecurityRealm/Details/config.jelly @@ -25,9 +25,9 @@ THE SOFTWARE. - + - + diff --git a/core/src/main/resources/hudson/security/HudsonPrivateSecurityRealm/config.jelly b/core/src/main/resources/hudson/security/HudsonPrivateSecurityRealm/config.jelly index a36f9c14f0..f638291ee6 100644 --- a/core/src/main/resources/hudson/security/HudsonPrivateSecurityRealm/config.jelly +++ b/core/src/main/resources/hudson/security/HudsonPrivateSecurityRealm/config.jelly @@ -27,6 +27,7 @@ THE SOFTWARE. + rule.validate("bad")); + assertThat(e.getMessage(), containsString("Password must be at least 8 characters long")); + + assertDoesNotThrow(() -> rule.validate("GoodPass1!")); + } + + @Test + void minimumLengthEnforced() { + BasicPasswordComplexityRule rule = new BasicPasswordComplexityRule(8, false, false, false, false); + + PasswordComplexityException e = assertThrows(PasswordComplexityException.class, () -> rule.validate("short")); + assertThat(e.getMessage(), containsString("Password must be at least 8 characters long")); + + assertDoesNotThrow(() -> rule.validate("longenough")); + } + + @Test + void uppercaseRequired() { + BasicPasswordComplexityRule rule = new BasicPasswordComplexityRule(0, true, false, false, false); + + PasswordComplexityException e = assertThrows(PasswordComplexityException.class, () -> rule.validate("alllowercase")); + assertThat(e.getMessage(), containsString("uppercase letter")); + + assertDoesNotThrow(() -> rule.validate("hasUppercase")); + } + + @Test + void lowercaseRequired() { + BasicPasswordComplexityRule rule = new BasicPasswordComplexityRule(0, false, true, false, false); + + PasswordComplexityException e = assertThrows(PasswordComplexityException.class, () -> rule.validate("ALLUPPERCASE")); + assertThat(e.getMessage(), containsString("lowercase letter")); + + assertDoesNotThrow(() -> rule.validate("HASLowercase")); + } + + @Test + void digitRequired() { + BasicPasswordComplexityRule rule = new BasicPasswordComplexityRule(0, false, false, true, false); + + PasswordComplexityException e = assertThrows(PasswordComplexityException.class, () -> rule.validate("NoDigitsHere")); + assertThat(e.getMessage(), containsString("digit")); + + assertDoesNotThrow(() -> rule.validate("Has1Digit")); + } + + @Test + void specialCharacterRequired() { + BasicPasswordComplexityRule rule = new BasicPasswordComplexityRule(0, false, false, false, true); + + PasswordComplexityException e = assertThrows(PasswordComplexityException.class, () -> rule.validate("NoSpecial123")); + assertThat(e.getMessage(), containsString("special character")); + + assertDoesNotThrow(() -> rule.validate("HasSpecial!")); + } + + @Test + void multipleViolationsReportedTogether() { + BasicPasswordComplexityRule rule = new BasicPasswordComplexityRule(8, true, true, true, true); + + PasswordComplexityException e = assertThrows(PasswordComplexityException.class, () -> rule.validate("bad")); + assertThat(e.getMessage(), allOf( + containsString("at least 8 characters"), + containsString("uppercase letter"), + containsString("digit"), + containsString("special character") + )); + } + + @Test + void noneRuleAcceptsAnyPassword() { + NonePasswordComplexityRule rule = new NonePasswordComplexityRule(); + assertDoesNotThrow(() -> rule.validate("a")); + assertDoesNotThrow(() -> rule.validate("")); + } + +} diff --git a/test/src/test/java/hudson/security/HudsonPrivateSecurityRealmTest.java b/test/src/test/java/hudson/security/HudsonPrivateSecurityRealmTest.java index 5bc6427750..e7d45cdd1f 100644 --- a/test/src/test/java/hudson/security/HudsonPrivateSecurityRealmTest.java +++ b/test/src/test/java/hudson/security/HudsonPrivateSecurityRealmTest.java @@ -30,6 +30,7 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; import static org.hamcrest.xml.HasXPath.hasXPath; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -43,6 +44,7 @@ import hudson.ExtensionList; import hudson.model.User; import hudson.security.HudsonPrivateSecurityRealm.Details; import hudson.security.pages.SignupPage; +import hudson.util.FormValidation; import java.lang.reflect.Field; import java.net.URI; import java.nio.charset.StandardCharsets; @@ -798,4 +800,149 @@ class HudsonPrivateSecurityRealmTest { XmlPage page = (XmlPage) wc.goTo("whoAmI/api/xml", "application/xml"); assertThat(page, hasXPath("//name", not(is(notExpectedUsername)))); } + + @Test + void noPasswordComplexityRuleAllowsAnyPassword() throws Exception { + HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(true, false, null); + j.jenkins.setSecurityRealm(securityRealm); + + JenkinsRule.WebClient wc = j.createWebClient(); + SignupPage signup = new SignupPage(wc.goTo("signup")); + signup.enterUsername("testuser"); + signup.enterPassword("a"); + signup.enterFullName("Test User"); + signup.enterEmail("test@example.com"); + HtmlPage success = signup.submit(j); + assertThat(success.getElementById("main-panel").getTextContent(), containsString("Success")); + } + + @Test + void signupRejectsWeakPasswordWhenComplexityRuleConfigured() throws Exception { + HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(true, false, null); + securityRealm.setPasswordComplexityRule(new BasicPasswordComplexityRule(8, true, false, true, false)); + j.jenkins.setSecurityRealm(securityRealm); + + JenkinsRule.WebClient wc = j.createWebClient(); + SignupPage signup = new SignupPage(wc.goTo("signup")); + signup.enterUsername("testuser"); + signup.enterPassword("weak"); + signup.enterFullName("Test User"); + signup.enterEmail("test@example.com"); + signup = new SignupPage(signup.submit(j)); + signup.assertErrorContains("Password must be at least 8 characters long"); + assertNull(User.get("testuser", false, Collections.emptyMap())); + } + + @Test + void signupAcceptsStrongPasswordWhenComplexityRuleConfigured() throws Exception { + HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(true, false, null); + securityRealm.setPasswordComplexityRule(new BasicPasswordComplexityRule(8, true, false, true, false)); + j.jenkins.setSecurityRealm(securityRealm); + + JenkinsRule.WebClient wc = j.createWebClient(); + SignupPage signup = new SignupPage(wc.goTo("signup")); + signup.enterUsername("testuser2"); + signup.enterPassword("StrongPass1"); + signup.enterFullName("Test User"); + signup.enterEmail("test@example.com"); + HtmlPage success = signup.submit(j); + assertThat(success.getElementById("main-panel").getTextContent(), containsString("Success")); + } + + @Test + void passwordChangeRejectsWeakPasswordWhenComplexityRuleConfigured() throws Exception { + HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(false, false, null); + securityRealm.setPasswordComplexityRule(new BasicPasswordComplexityRule(8, true, false, true, false)); + j.jenkins.setSecurityRealm(securityRealm); + + User alice = securityRealm.createAccount("alice", "AlicePass1"); + + JenkinsRule.WebClient wc = j.createWebClient(); + wc.login("alice", "AlicePass1"); + + HtmlPage configurePage = wc.goTo(alice.getUrl() + "/security/"); + HtmlPasswordInput password1 = configurePage.getElementByName("user.password"); + HtmlPasswordInput password2 = configurePage.getElementByName("user.password2"); + + password1.setText("weak"); + password2.setText("weak"); + + HtmlForm form = configurePage.getFormByName("config"); + assertThrows(FailingHttpStatusCodeException.class, () -> j.submit(form)); + } + + @Test + void passwordChangeAcceptsStrongPasswordWhenComplexityRuleConfigured() throws Exception { + HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(false, false, null); + securityRealm.setPasswordComplexityRule(new BasicPasswordComplexityRule(8, true, false, true, false)); + j.jenkins.setSecurityRealm(securityRealm); + + User alice = securityRealm.createAccount("alice", "AlicePass1"); + + JenkinsRule.WebClient wc = j.createWebClient(); + wc.login("alice", "AlicePass1"); + + HtmlPage configurePage = wc.goTo(alice.getUrl() + "/security/"); + HtmlPasswordInput password1 = configurePage.getElementByName("user.password"); + HtmlPasswordInput password2 = configurePage.getElementByName("user.password2"); + + password1.setText("NewStrong1"); + password2.setText("NewStrong1"); + + HtmlForm form = configurePage.getFormByName("config"); + j.submit(form); + + wc.login("alice", "NewStrong1"); + assertUserConnected(wc, "alice"); + } + + @Test + void doCheckPasswordReturnsErrorForWeakPassword() throws Exception { + HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(false, false, null); + securityRealm.setPasswordComplexityRule(new BasicPasswordComplexityRule(8, true, false, false, false)); + j.jenkins.setSecurityRealm(securityRealm); + + Details.DescriptorImpl descriptor = ExtensionList.lookupSingleton(Details.DescriptorImpl.class); + FormValidation result = descriptor.doCheckPassword("weak"); + assertEquals(FormValidation.Kind.ERROR, result.kind); + } + + @Test + void doCheckPasswordReturnsOkForStrongPassword() throws Exception { + HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(false, false, null); + securityRealm.setPasswordComplexityRule(new BasicPasswordComplexityRule(8, true, false, false, false)); + j.jenkins.setSecurityRealm(securityRealm); + + Details.DescriptorImpl descriptor = ExtensionList.lookupSingleton(Details.DescriptorImpl.class); + FormValidation result = descriptor.doCheckPassword("StrongPass1"); + assertEquals(FormValidation.Kind.OK, result.kind); + } + + @Test + void doCheckPasswordReturnsOkWhenNoRuleConfigured() throws Exception { + HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(false, false, null); + j.jenkins.setSecurityRealm(securityRealm); + + Details.DescriptorImpl descriptor = ExtensionList.lookupSingleton(Details.DescriptorImpl.class); + FormValidation result = descriptor.doCheckPassword("weak"); + assertEquals(FormValidation.Kind.OK, result.kind); + } + + @Test + void doCheckPasswordReturnsOkForEmptyPassword() throws Exception { + HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(false, false, null); + securityRealm.setPasswordComplexityRule(new BasicPasswordComplexityRule(8, true, false, false, false)); + j.jenkins.setSecurityRealm(securityRealm); + + Details.DescriptorImpl descriptor = ExtensionList.lookupSingleton(Details.DescriptorImpl.class); + FormValidation result = descriptor.doCheckPassword(""); + assertEquals(FormValidation.Kind.OK, result.kind); + } + + @Test + void setNullPasswordComplexityRuleFallsBackToNone() { + HudsonPrivateSecurityRealm realm = new HudsonPrivateSecurityRealm(false, false, null); + realm.setPasswordComplexityRule(null); + assertDoesNotThrow(() -> realm.getPasswordComplexityRule().validate("weak")); + } }