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")); + } }