diff --git a/core/src/main/java/hudson/Util.java b/core/src/main/java/hudson/Util.java index 3039bbc641..3fc41e56c1 100644 --- a/core/src/main/java/hudson/Util.java +++ b/core/src/main/java/hudson/Util.java @@ -92,8 +92,9 @@ import javax.annotation.Nullable; import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; -import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.io.FileUtils; +import org.kohsuke.stapler.Ancestor; +import org.kohsuke.stapler.StaplerRequest; /** * Various utility methods that don't have more proper home. @@ -1684,6 +1685,19 @@ public class Util { return Math.max(0, daysBetween(date, new Date())); } + /** + * Find the specific ancestor, or throw an exception. + * Useful for an ancestor we know is inside the URL to ease readability + */ + @Restricted(NoExternalUse.class) + public static @Nonnull T getNearestAncestorOfTypeOrThrow(@Nonnull StaplerRequest request, @Nonnull Class clazz) { + T t = request.findAncestorObject(clazz); + if (t == null) { + throw new IllegalArgumentException("No ancestor of type " + clazz.getName() + " in the request"); + } + return t; + } + public static final FastDateFormat XS_DATETIME_FORMATTER = FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss'Z'",new SimpleTimeZone(0,"GMT")); // Note: RFC822 dates must not be localized! diff --git a/core/src/main/java/hudson/cli/ClientAuthenticationCache.java b/core/src/main/java/hudson/cli/ClientAuthenticationCache.java index 2cd45a7776..611f3918d9 100644 --- a/core/src/main/java/hudson/cli/ClientAuthenticationCache.java +++ b/core/src/main/java/hudson/cli/ClientAuthenticationCache.java @@ -2,13 +2,16 @@ package hudson.cli; import com.google.common.annotations.VisibleForTesting; import hudson.FilePath; +import hudson.model.User; import hudson.remoting.Channel; import hudson.util.Secret; import jenkins.model.Jenkins; import jenkins.security.MasterToSlaveCallable; +import jenkins.security.seed.UserSeedProperty; import org.acegisecurity.Authentication; import org.acegisecurity.AuthenticationException; import org.acegisecurity.providers.UsernamePasswordAuthenticationToken; +import org.acegisecurity.providers.anonymous.AnonymousAuthenticationToken; import org.acegisecurity.userdetails.UserDetails; import org.springframework.dao.DataAccessException; @@ -17,6 +20,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.Serializable; +import java.util.Arrays; import java.util.Properties; import java.util.logging.Level; import java.util.logging.Logger; @@ -24,6 +28,8 @@ import jenkins.security.HMACConfidentialKey; import javax.annotation.Nonnull; +import javax.annotation.CheckForNull; + /** * Represents the authentication credential store of the CLI client. * @@ -40,7 +46,10 @@ public class ClientAuthenticationCache implements Serializable { private static final HMACConfidentialKey MAC = new HMACConfidentialKey(ClientAuthenticationCache.class, "MAC"); private static final Logger LOGGER = Logger.getLogger(ClientAuthenticationCache.class.getName()); - + private static final String VERIFICATION_FRAGMENT_SEPARATOR = "_"; + private static final String USERNAME_VERIFICATION_SEPARATOR = ":"; + private static final String VERSION_2 = "v2"; + /** * Where the store should be placed. */ @@ -67,7 +76,6 @@ public class ClientAuthenticationCache implements Serializable { * @return {@link jenkins.model.Jenkins#ANONYMOUS} if no such credential is found, or if the stored credential is invalid. */ public @Nonnull Authentication get() { - Jenkins h = Jenkins.getActiveInstance(); String val = props.getProperty(getPropertyKey()); if (val == null) { LOGGER.finer("No stored CLI authentication"); @@ -78,18 +86,97 @@ public class ClientAuthenticationCache implements Serializable { LOGGER.log(Level.FINE, "Ignoring insecure stored CLI authentication for {0}", oldSecret.getPlainText()); return Jenkins.ANONYMOUS; } - int idx = val.lastIndexOf(':'); + int idx = val.lastIndexOf(USERNAME_VERIFICATION_SEPARATOR); if (idx == -1) { LOGGER.log(Level.FINE, "Ignoring malformed stored CLI authentication: {0}", val); return Jenkins.ANONYMOUS; } String username = val.substring(0, idx); - if (!MAC.checkMac(username, val.substring(idx + 1))) { - LOGGER.log(Level.FINE, "Ignoring stored CLI authentication due to MAC mismatch: {0}", val); + String verificationPart = val.substring(idx + 1); + int indexOfSeparator = verificationPart.indexOf(VERIFICATION_FRAGMENT_SEPARATOR); + if (indexOfSeparator == -1) { + return legacy(username, verificationPart, val); + } + + /* + * Format of the cache data: [username]:[verificationToken] + * Where the verificationToken is: [mac]_[version]_[restOfFragments] + */ + + String[] verificationFragments = verificationPart.split(VERIFICATION_FRAGMENT_SEPARATOR); + if (verificationFragments.length < 2) { + LOGGER.log(Level.FINE, "Ignoring malformed stored CLI authentication verification: {0}", val); + return Jenkins.ANONYMOUS; + } + + // the mac is only verifying the username + String macFragment = verificationFragments[0]; + String version = verificationFragments[1]; + String[] restOfFragments = Arrays.copyOfRange(verificationFragments, 2, verificationFragments.length); + + Authentication authFromVersion; + if (VERSION_2.equals(version)) { + authFromVersion = version2(username, restOfFragments, val); + } else { + LOGGER.log(Level.FINE, "Unrecognized version for stored CLI authentication verification: {0}", val); + return Jenkins.ANONYMOUS; + } + + if (authFromVersion != null) { + return authFromVersion; + } + + return getUserAuthIfValidMac(username, macFragment, val); + } + + private Authentication legacy(String username, String mac, String fullValueStored){ + return getUserAuthIfValidMac(username, mac, fullValueStored); + } + + /** + * restOfFragments format: [userSeed] + * + * @return {@code null} when the method wants to let the default behavior to proceed + */ + private @CheckForNull Authentication version2(String username, String[] restOfFragments, String fullValueStored){ + if (restOfFragments.length != 1) { + LOGGER.log(Level.FINE, "Number of fragments invalid for stored CLI authentication verification: {0}", fullValueStored); + return Jenkins.ANONYMOUS; + } + + if (UserSeedProperty.DISABLE_USER_SEED) { + return null; + } + + User user = User.getById(username, false); + if (user == null) { + LOGGER.log(Level.FINE, "User not found for stored CLI authentication verification: {0}", fullValueStored); + return Jenkins.ANONYMOUS; + } + + UserSeedProperty property = user.getProperty(UserSeedProperty.class); + if (property == null) { + LOGGER.log(Level.INFO, "User does not have a user seed but one is contained in CLI authentication: {0}", fullValueStored); + return Jenkins.ANONYMOUS; + } + + String receivedUserSeed = restOfFragments[0]; + String actualUserSeed = property.getSeed(); + if (!receivedUserSeed.equals(actualUserSeed)) { + LOGGER.log(Level.FINE, "Actual user seed does not correspond to the one in stored CLI authentication: {0}", fullValueStored); + return Jenkins.ANONYMOUS; + } + + return null; + } + + private Authentication getUserAuthIfValidMac(String username, String mac, String fullValueStored) { + if (!MAC.checkMac(username, mac)) { + LOGGER.log(Level.FINE, "Ignoring stored CLI authentication due to MAC mismatch: {0}", fullValueStored); return Jenkins.ANONYMOUS; } try { - UserDetails u = h.getSecurityRealm().loadUserByUsername(username); + UserDetails u = Jenkins.get().getSecurityRealm().loadUserByUsername(username); LOGGER.log(Level.FINER, "Loaded stored CLI authentication for {0}", username); return new UsernamePasswordAuthenticationToken(u.getUsername(), "", u.getAuthorities()); } catch (AuthenticationException | DataAccessException e) { @@ -121,11 +208,48 @@ public class ClientAuthenticationCache implements Serializable { // as it's not required. UserDetails u = h.getSecurityRealm().loadUserByUsername(a.getName()); String username = u.getUsername(); - props.setProperty(getPropertyKey(), username + ":" + MAC.mac(username)); + + User user; + if (a instanceof AnonymousAuthenticationToken) { + user = null; + } else { + user = User.getById(a.getName(), false); + } + + if (user == null) { + // anonymous case or user not existing case, but normally should not occur + // since the only call to it is by LoginCommand after a non-anonymous login. + setUsingLegacyMethod(username); + return; + } + + String userSeed; + UserSeedProperty userSeedProperty = user.getProperty(UserSeedProperty.class); + if (userSeedProperty == null) { + userSeed = "no-user-seed"; + } else { + userSeed = userSeedProperty.getSeed(); + } + String mac = getMacOf(username); + String validationFragment = String.join(VERIFICATION_FRAGMENT_SEPARATOR, mac, VERSION_2, userSeed); + + String propertyValue = username + USERNAME_VERIFICATION_SEPARATOR + validationFragment; + props.setProperty(getPropertyKey(), propertyValue); save(); } + @VisibleForTesting + void setUsingLegacyMethod(String username) throws IOException, InterruptedException { + props.setProperty(getPropertyKey(), username + USERNAME_VERIFICATION_SEPARATOR + getMacOf(username)); + save(); + } + + @VisibleForTesting + @Nonnull String getMacOf(@Nonnull String value){ + return MAC.mac(value); + } + /** * Removes the persisted credential, if there's one. */ diff --git a/core/src/main/java/hudson/search/Search.java b/core/src/main/java/hudson/search/Search.java index 41d359765a..cdb2ebcadd 100644 --- a/core/src/main/java/hudson/search/Search.java +++ b/core/src/main/java/hudson/search/Search.java @@ -40,6 +40,7 @@ import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; +import javax.annotation.CheckForNull; import javax.servlet.ServletException; import jenkins.model.Jenkins; @@ -155,7 +156,7 @@ public class Search implements StaplerProxy { return r; } - private SearchableModelObject findClosestSearchableModelObject(StaplerRequest req) { + private @CheckForNull SearchableModelObject findClosestSearchableModelObject(StaplerRequest req) { List l = req.getAncestors(); for( int i=l.size()-1; i>=0; i-- ) { Ancestor a = l.get(i); diff --git a/core/src/main/java/hudson/security/AuthenticationProcessingFilter2.java b/core/src/main/java/hudson/security/AuthenticationProcessingFilter2.java index bda002cf10..23301683d8 100644 --- a/core/src/main/java/hudson/security/AuthenticationProcessingFilter2.java +++ b/core/src/main/java/hudson/security/AuthenticationProcessingFilter2.java @@ -30,9 +30,12 @@ import java.io.IOException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; import hudson.Util; +import hudson.model.User; import jenkins.security.SecurityListener; +import jenkins.security.seed.UserSeedProperty; import org.acegisecurity.Authentication; import org.acegisecurity.AuthenticationException; import org.acegisecurity.ui.webapp.AuthenticationProcessingFilter; @@ -87,7 +90,19 @@ public class AuthenticationProcessingFilter2 extends AuthenticationProcessingFil // (either when a redirect is issued, via its HttpResponseWrapper, or when the execution returns to its // doFilter method. request.getSession().invalidate(); - request.getSession(); + HttpSession newSession = request.getSession(); + + if (!UserSeedProperty.DISABLE_USER_SEED) { + User user = User.getById(authResult.getName(), true); + + UserSeedProperty userSeed = user.getProperty(UserSeedProperty.class); + String sessionSeed = userSeed.getSeed(); + newSession.setAttribute(UserSeedProperty.USER_SESSION_SEED, sessionSeed); + } + + // as the request comes from Acegi redirect, that's not a Stapler one + // thus it's not possible to retrieve it in the SecurityListener in that case + // for that reason we need to keep the above code that apply quite the same logic as UserSeedSecurityListener SecurityListener.fireLoggedIn(authResult.getName()); } diff --git a/core/src/main/java/hudson/security/HttpSessionContextIntegrationFilter2.java b/core/src/main/java/hudson/security/HttpSessionContextIntegrationFilter2.java index e197c872bf..7a67c77dd0 100644 --- a/core/src/main/java/hudson/security/HttpSessionContextIntegrationFilter2.java +++ b/core/src/main/java/hudson/security/HttpSessionContextIntegrationFilter2.java @@ -23,10 +23,13 @@ */ package hudson.security; +import hudson.model.User; import jenkins.security.NonSerializableSecurityContext; +import jenkins.security.seed.UserSeedProperty; import org.acegisecurity.context.HttpSessionContextIntegrationFilter; import org.acegisecurity.context.SecurityContext; import org.acegisecurity.Authentication; +import org.acegisecurity.providers.anonymous.AnonymousAuthenticationToken; import javax.servlet.ServletException; import javax.servlet.ServletRequest; @@ -49,16 +52,13 @@ public class HttpSessionContextIntegrationFilter2 extends HttpSessionContextInte public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpSession session = ((HttpServletRequest) req).getSession(false); - if(session!=null) { - SecurityContext o = (SecurityContext)session.getAttribute(ACEGI_SECURITY_CONTEXT_KEY); - if(o!=null) { + if (session != null) { + SecurityContext o = (SecurityContext) session.getAttribute(ACEGI_SECURITY_CONTEXT_KEY); + if (o != null) { Authentication a = o.getAuthentication(); - if(a!=null) { - if (a.getPrincipal() instanceof InvalidatableUserDetails) { - InvalidatableUserDetails ud = (InvalidatableUserDetails) a.getPrincipal(); - if(ud.isInvalid()) - // don't let Acegi see invalid security context - session.setAttribute(ACEGI_SECURITY_CONTEXT_KEY,null); + if (a != null) { + if (isAuthInvalidated(a) || hasInvalidSessionSeed(a, session)) { + session.setAttribute(ACEGI_SECURITY_CONTEXT_KEY, null); } } } @@ -66,4 +66,50 @@ public class HttpSessionContextIntegrationFilter2 extends HttpSessionContextInte super.doFilter(req, res, chain); } + + private boolean isAuthInvalidated(Authentication authentication) { + if (authentication.getPrincipal() instanceof InvalidatableUserDetails) { + InvalidatableUserDetails ud = (InvalidatableUserDetails) authentication.getPrincipal(); + if (ud.isInvalid()) { + // don't let Acegi see invalid security context + return true; + } + } + + return false; + } + + private boolean hasInvalidSessionSeed(Authentication authentication, HttpSession session) { + if (UserSeedProperty.DISABLE_USER_SEED || authentication instanceof AnonymousAuthenticationToken) { + return false; + } + + User userFromSession = User.getById(authentication.getName(), false); + if (userFromSession == null) { + // no requirement for further test as there is no user inside + return false; + } + + // for case like recovering backup or other corner cases when the session was not populated by this version + Object userSessionSeedObject = session.getAttribute(UserSeedProperty.USER_SESSION_SEED); + String actualUserSessionSeed; + if (userSessionSeedObject instanceof String) { + actualUserSessionSeed = (String) userSessionSeedObject; + } else { + // the seed must be present AND be a string in the session + return true; + } + + UserSeedProperty userSeedProperty = userFromSession.getProperty(UserSeedProperty.class); + if (userSeedProperty == null) { + // if you want to filter out the user seed property, you should consider using the DISABLE_USER_SEED instead + return true; + } + // no need to do a time-constant test here because all the information come from the server + // in other words, there is no way for a user to brute-force those values + boolean validSeed = actualUserSessionSeed.equals(userSeedProperty.getSeed()); + + // if the authentication is no longer valid we need to remove it from the session + return !validSeed; + } } diff --git a/core/src/main/java/hudson/security/HudsonPrivateSecurityRealm.java b/core/src/main/java/hudson/security/HudsonPrivateSecurityRealm.java index c777f428a9..21e608a372 100644 --- a/core/src/main/java/hudson/security/HudsonPrivateSecurityRealm.java +++ b/core/src/main/java/hudson/security/HudsonPrivateSecurityRealm.java @@ -42,6 +42,7 @@ import hudson.util.Protector; import hudson.util.Scrambler; import hudson.util.XStream2; import jenkins.security.SecurityListener; +import jenkins.security.seed.UserSeedProperty; import net.sf.json.JSONObject; import org.acegisecurity.Authentication; import org.acegisecurity.AuthenticationException; @@ -693,6 +694,14 @@ public class HudsonPrivateSecurityRealm extends AbstractPasswordBasedSecurityRea if(data.startsWith(prefix)) return Details.fromHashedPassword(data.substring(prefix.length())); } + + User user = Util.getNearestAncestorOfTypeOrThrow(req, User.class); + // the UserSeedProperty is not touched by the configure page + UserSeedProperty userSeedProperty = user.getProperty(UserSeedProperty.class); + if (userSeedProperty != null) { + userSeedProperty.renewSeed(); + } + return Details.fromPlainPassword(Util.fixNull(pwd)); } diff --git a/core/src/main/java/hudson/security/TokenBasedRememberMeServices2.java b/core/src/main/java/hudson/security/TokenBasedRememberMeServices2.java index ff0f374ca4..c93d4c77c2 100644 --- a/core/src/main/java/hudson/security/TokenBasedRememberMeServices2.java +++ b/core/src/main/java/hudson/security/TokenBasedRememberMeServices2.java @@ -24,23 +24,37 @@ package hudson.security; import hudson.Functions; +import hudson.model.User; import jenkins.model.Jenkins; import jenkins.security.HMACConfidentialKey; import jenkins.security.ImpersonatingUserDetailsService; import jenkins.security.LastGrantedAuthoritiesProperty; +import jenkins.security.seed.UserSeedProperty; +import jenkins.util.SystemProperties; import org.acegisecurity.Authentication; +import org.acegisecurity.providers.rememberme.RememberMeAuthenticationToken; import org.acegisecurity.ui.rememberme.TokenBasedRememberMeServices; import org.acegisecurity.userdetails.UserDetails; import org.acegisecurity.userdetails.UserDetailsService; import org.apache.commons.codec.binary.Base64; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.util.Date; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; /** * {@link TokenBasedRememberMeServices} with modification so as not to rely @@ -53,6 +67,16 @@ import java.util.Date; * @author Kohsuke Kawaguchi */ public class TokenBasedRememberMeServices2 extends TokenBasedRememberMeServices { + + private static final Logger LOGGER = Logger.getLogger(TokenBasedRememberMeServices2.class.getName()); + + /** + * Escape hatch for the check on the maximum date for the expiration duration of the remember me cookie + */ + @Restricted(NoExternalUse.class) + public static /* Script Console modifiable */ boolean SKIP_TOO_FAR_EXPIRATION_DATE_CHECK = + SystemProperties.getBoolean(TokenBasedRememberMeServices2.class.getName() + ".skipTooFarExpirationDateCheck"); + /** * Decorate {@link UserDetailsService} so that we can use information stored in * {@link LastGrantedAuthoritiesProperty}. @@ -70,9 +94,23 @@ public class TokenBasedRememberMeServices2 extends TokenBasedRememberMeServices @Override protected String makeTokenSignature(long tokenExpiryTime, UserDetails userDetails) { - String expectedTokenSignature = MAC.mac(userDetails.getUsername() + ":" + tokenExpiryTime + ":" - + "N/A" + ":" + getKey()); - return expectedTokenSignature; + String userSeed; + if (UserSeedProperty.DISABLE_USER_SEED) { + userSeed = "no-seed"; + } else { + User user = User.getById(userDetails.getUsername(), false); + if (user == null) { + return "no-user"; + } + UserSeedProperty userSeedProperty = user.getProperty(UserSeedProperty.class); + if (userSeedProperty == null) { + // if you want to filter out the user seed property, you should consider using the DISABLE_USER_SEED instead + return "no-prop"; + } + userSeed = userSeedProperty.getSeed(); + } + String token = String.join(":", userDetails.getUsername(), Long.toString(tokenExpiryTime), userSeed, getKey()); + return MAC.mac(token); } @Override @@ -107,7 +145,7 @@ public class TokenBasedRememberMeServices2 extends TokenBasedRememberMeServices Assert.notNull(successfulAuthentication.getCredentials()); Assert.isInstanceOf(UserDetails.class, successfulAuthentication.getPrincipal()); - long expiryTime = System.currentTimeMillis() + (tokenValiditySeconds * 1000); + long expiryTime = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(tokenValiditySeconds); String username = ((UserDetails) successfulAuthentication.getPrincipal()).getUsername(); String signatureValue = makeTokenSignature(expiryTime, (UserDetails)successfulAuthentication.getPrincipal()); @@ -128,7 +166,12 @@ public class TokenBasedRememberMeServices2 extends TokenBasedRememberMeServices return null; }else { try { - return super.autoLogin(request, response); + // we use a patched version of the super.autoLogin + String rememberMeValue = findRememberMeCookieValue(request, response); + if (rememberMeValue == null) { + return null; + } + return retrieveAuthFromCookie(request, response, rememberMeValue); } catch (Exception e) { cancelCookie(request, response, "Failed to handle remember-me cookie: " + Functions.printThrowable(e)); return null; @@ -136,6 +179,116 @@ public class TokenBasedRememberMeServices2 extends TokenBasedRememberMeServices } } + /** + * Patched version of the super.autoLogin with a time-independent equality check for the token validation + */ + private String findRememberMeCookieValue(HttpServletRequest request, HttpServletResponse response) { + Cookie[] cookies = request.getCookies(); + + if ((cookies == null) || (cookies.length == 0)) { + return null; + } + + for (int i = 0; i < cookies.length; i++) { + if (ACEGI_SECURITY_HASHED_REMEMBER_ME_COOKIE_KEY.equals(cookies[i].getName())) { + return cookies[i].getValue(); + } + } + + return null; + } + + // Taken from TokenBasedRememberMeService with slight modification + // around the token equality check to avoid timing attack + // and reducing drastically the information provided in the log to avoid potential disclosure + private @CheckForNull Authentication retrieveAuthFromCookie(HttpServletRequest request, HttpServletResponse response, String cookieValueBase64){ + String cookieValue = decodeCookieBase64(cookieValueBase64); + if (cookieValue == null) { + String reason = "Cookie token was not Base64 encoded; value was '" + cookieValueBase64 + "'"; + cancelCookie(request, response, reason); + return null; + } + if (logger.isDebugEnabled()) { + logger.debug("Remember-me cookie detected"); + } + + String[] cookieTokens = StringUtils.delimitedListToStringArray(cookieValue, ":"); + + if (cookieTokens.length != 3) { + cancelCookie(request, response, "Cookie token did not contain 3 tokens separated by [:]"); + return null; + } + + long tokenExpiryTime; + + try { + tokenExpiryTime = Long.parseLong(cookieTokens[1]); + } + catch (NumberFormatException nfe) { + cancelCookie(request, response, "Cookie token[1] did not contain a valid number"); + return null; + } + + if (isTokenExpired(tokenExpiryTime)) { + cancelCookie(request, response, "Cookie token[1] has expired"); + return null; + } + + // Check the user exists + // Defer lookup until after expiry time checked, to + // possibly avoid expensive lookup + UserDetails userDetails = loadUserDetails(request, response, cookieTokens); + + if (userDetails == null) { + cancelCookie(request, response, "Cookie token[0] contained a username without user associated"); + return null; + } + + if (!isValidUserDetails(request, response, userDetails, cookieTokens)) { + return null; + } + + String receivedTokenSignature = cookieTokens[2]; + String expectedTokenSignature = makeTokenSignature(tokenExpiryTime, userDetails); + + boolean tokenValid = MessageDigest.isEqual( + expectedTokenSignature.getBytes(StandardCharsets.US_ASCII), + receivedTokenSignature.getBytes(StandardCharsets.US_ASCII) + ); + if (!tokenValid) { + cancelCookie(request, response, "Cookie token[2] contained invalid signature"); + return null; + } + + // By this stage we have a valid token + if (logger.isDebugEnabled()) { + logger.debug("Remember-me cookie accepted"); + } + + RememberMeAuthenticationToken auth = new RememberMeAuthenticationToken(this.getKey(), userDetails, + userDetails.getAuthorities()); + auth.setDetails(authenticationDetailsSource.buildDetails(request)); + + return auth; + } + + /** + * @return the decoded base64 of the cookie or {@code null} if the value was not correctly encoded + */ + private @CheckForNull String decodeCookieBase64(@Nonnull String base64EncodedValue){ + for (int j = 0; j < base64EncodedValue.length() % 4; j++) { + base64EncodedValue = base64EncodedValue + "="; + } + + try { + // any charset should be fine but better safe than sorry + byte[] decodedPlainValue = java.util.Base64.getDecoder().decode(base64EncodedValue.getBytes(StandardCharsets.UTF_8)); + return new String(decodedPlainValue, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + return null; + } + } + @Override protected Cookie makeValidCookie(String tokenValueBase64, HttpServletRequest request, long maxAge) { Cookie cookie = super.makeValidCookie(tokenValueBase64, request, maxAge); @@ -171,6 +324,28 @@ public class TokenBasedRememberMeServices2 extends TokenBasedRememberMeServices cookie.setSecure(request.isSecure()); } + /** + * In addition to the expiration requested by the super class, we also check the expiration is not too far in the future. + * Especially to detect maliciously crafted cookie. + */ + @Override + protected boolean isTokenExpired(long tokenExpiryTimeMs) { + long nowMs = System.currentTimeMillis(); + long maxExpirationMs = TimeUnit.SECONDS.toMillis(tokenValiditySeconds) + nowMs; + if(!SKIP_TOO_FAR_EXPIRATION_DATE_CHECK && tokenExpiryTimeMs > maxExpirationMs){ + // attempt to use a cookie that has more than the maximum allowed expiration duration + // was either created before a change of configuration or maliciously crafted + long diffMs = tokenExpiryTimeMs - maxExpirationMs; + LOGGER.log(Level.WARNING, "Attempt to use a cookie with an expiration duration larger than the one configured (delta of: {0} ms)", diffMs); + return true; + } + // Check it has not expired + if (tokenExpiryTimeMs < nowMs) { + return true; + } + return false; + } + /** * Used to compute the token signature securely. */ diff --git a/core/src/main/java/jenkins/install/SetupWizard.java b/core/src/main/java/jenkins/install/SetupWizard.java index 3ae5a0ea24..a59777079e 100644 --- a/core/src/main/java/jenkins/install/SetupWizard.java +++ b/core/src/main/java/jenkins/install/SetupWizard.java @@ -21,8 +21,10 @@ import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequestWrapper; import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; import jenkins.model.JenkinsLocationConfiguration; +import jenkins.security.seed.UserSeedProperty; import jenkins.util.SystemProperties; import jenkins.util.UrlHelper; import org.acegisecurity.Authentication; @@ -272,6 +274,19 @@ public class SetupWizard extends PageDecorator { Authentication auth = new UsernamePasswordAuthenticationToken(newUser.getId(), req.getParameter("password1")); auth = securityRealm.getSecurityComponents().manager.authenticate(auth); SecurityContextHolder.getContext().setAuthentication(auth); + + HttpSession session = req.getSession(false); + if (session != null) { + // avoid session fixation + session.invalidate(); + } + HttpSession newSession = req.getSession(true); + + UserSeedProperty userSeed = newUser.getProperty(UserSeedProperty.class); + String sessionSeed = userSeed.getSeed(); + // include the new seed + newSession.setAttribute(UserSeedProperty.USER_SESSION_SEED, sessionSeed); + CrumbIssuer crumbIssuer = Jenkins.getInstance().getCrumbIssuer(); JSONObject data = new JSONObject(); if (crumbIssuer != null) { @@ -291,7 +306,7 @@ public class SetupWizard extends PageDecorator { admin.save(); // recreate this initial user if something failed } } - } + } @RequirePOST @Restricted(NoExternalUse.class) diff --git a/core/src/main/java/jenkins/security/seed/UserSeedChangeListener.java b/core/src/main/java/jenkins/security/seed/UserSeedChangeListener.java new file mode 100644 index 0000000000..d8f9173952 --- /dev/null +++ b/core/src/main/java/jenkins/security/seed/UserSeedChangeListener.java @@ -0,0 +1,70 @@ +/* + * The MIT License + * + * Copyright (c) 2018, CloudBees, Inc. + * + * 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 jenkins.security.seed; + +import hudson.ExtensionList; +import hudson.model.User; +import jenkins.security.SecurityListener; +import org.apache.tools.ant.ExtensionPoint; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +import javax.annotation.Nonnull; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Listener notified when a user was requested to changed their seed + */ +//TODO remove restriction on the weekly after the security fix +@Restricted(NoExternalUse.class) +public abstract class UserSeedChangeListener extends ExtensionPoint { + private static final Logger LOGGER = Logger.getLogger(SecurityListener.class.getName()); + + /** + * Called after a seed was changed but before the user is saved. + * @param user The target user + */ + public abstract void onUserSeedRenewed(@Nonnull User user); + + /** + * Will notify all the registered listeners about the event + * @param user The target user + */ + public static void fireUserSeedRenewed(@Nonnull User user) { + for (UserSeedChangeListener l : all()) { + try { + l.onUserSeedRenewed(user); + } + catch (Exception e) { + LOGGER.log(Level.WARNING, "Exception caught during onUserSeedRenewed event", e); + } + } + } + + private static List all() { + return ExtensionList.lookup(UserSeedChangeListener.class); + } +} diff --git a/core/src/main/java/jenkins/security/seed/UserSeedProperty.java b/core/src/main/java/jenkins/security/seed/UserSeedProperty.java new file mode 100644 index 0000000000..cf93851721 --- /dev/null +++ b/core/src/main/java/jenkins/security/seed/UserSeedProperty.java @@ -0,0 +1,152 @@ +/* + * The MIT License + * + * Copyright (c) 2018, CloudBees, Inc. + * + * 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 jenkins.security.seed; + +import hudson.BulkChange; +import hudson.Extension; +import hudson.model.User; +import hudson.model.UserProperty; +import hudson.model.UserPropertyDescriptor; +import hudson.util.HttpResponses; +import jenkins.model.Jenkins; +import jenkins.security.LastGrantedAuthoritiesProperty; +import jenkins.util.SystemProperties; +import org.apache.commons.codec.binary.Hex; +import org.jenkinsci.Symbol; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.DoNotUse; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.AncestorInPath; +import org.kohsuke.stapler.HttpResponse; +import org.kohsuke.stapler.interceptor.RequirePOST; + +import javax.annotation.Nonnull; +import java.io.IOException; +import java.security.SecureRandom; +import java.util.Objects; + +/** + * The seed stored in this property is used to have a revoke feature on the session + * without having to hack the session management that depends on the application server used to run the instance. + * + * The seed is added to the session when a user just logged in and then for every request, + * before using the session information, we check the seed was not changed in the meantime. + * + * This feature allows the admin to revoke all the sessions that are in the wild without having to keep a list of them. + * + * @see hudson.security.AuthenticationProcessingFilter2 for the addition of seed inside the session + * @see hudson.security.HttpSessionContextIntegrationFilter2 for the seed check from the session before using it + */ +//TODO remove restriction on the weekly after the security fix +@Restricted(NoExternalUse.class) +public class UserSeedProperty extends UserProperty { + /** + * Escape hatch for User seed based revocation feature. + * If we disable the seed, we can still use it to write / store information but not verifying the data using it. + */ + @Restricted(NoExternalUse.class) + public static /* Script Console modifiable */ boolean DISABLE_USER_SEED = SystemProperties.getBoolean(UserSeedProperty.class.getName() + ".disableUserSeed"); + + /** + * Hide the user seed section from the UI to prevent accidental use + */ + @Restricted(NoExternalUse.class) + public static /* Script Console modifiable */ boolean HIDE_USER_SEED_SECTION = SystemProperties.getBoolean(UserSeedProperty.class.getName() + ".hideUserSeedSection"); + + public static final String USER_SESSION_SEED = "_JENKINS_SESSION_SEED"; + + private static final SecureRandom RANDOM = new SecureRandom(); + private static final int SEED_NUM_BYTES = 8; + + private String seed; + + private UserSeedProperty() { + this.renewSeedInternal(); + } + + public @Nonnull String getSeed() { + return seed; + } + + public void renewSeed() { + this.renewSeedInternal(); + + UserSeedChangeListener.fireUserSeedRenewed(this.user); + } + + private void renewSeedInternal() { + String currentSeed = this.seed; + String newSeed = currentSeed; + while (Objects.equals(newSeed, currentSeed)) { + newSeed = new String(Hex.encodeHex(RANDOM.generateSeed(SEED_NUM_BYTES))); + } + this.seed = newSeed; + } + + @Extension + @Symbol("userSeed") + public static final class DescriptorImpl extends UserPropertyDescriptor { + public @Nonnull String getDisplayName() { + return Messages.UserSeedProperty_DisplayName(); + } + + public UserSeedProperty newInstance(User user) { + return new UserSeedProperty(); + } + + // only for jelly + @Restricted(DoNotUse.class) + public boolean isCurrentUser(@Nonnull User target) { + return Objects.equals(User.current(), target); + } + + @RequirePOST + public synchronized HttpResponse doRenewSessionSeed(@AncestorInPath @Nonnull User u) throws IOException { + u.checkPermission(Jenkins.ADMINISTER); + + if (DISABLE_USER_SEED) { + return HttpResponses.error(404, "User seed feature is disabled"); + } + + try (BulkChange bc = new BulkChange(u)) { + UserSeedProperty p = u.getProperty(UserSeedProperty.class); + p.renewSeed(); + + LastGrantedAuthoritiesProperty lastGranted = u.getProperty(LastGrantedAuthoritiesProperty.class); + if (lastGranted != null) { + lastGranted.invalidate(); + } + + bc.commit(); + } + + return HttpResponses.ok(); + } + + @Override + public boolean isEnabled() { + return !DISABLE_USER_SEED && !HIDE_USER_SEED_SECTION; + } + } +} diff --git a/core/src/main/java/jenkins/security/seed/UserSeedSecurityListener.java b/core/src/main/java/jenkins/security/seed/UserSeedSecurityListener.java new file mode 100644 index 0000000000..8f4fbf5d54 --- /dev/null +++ b/core/src/main/java/jenkins/security/seed/UserSeedSecurityListener.java @@ -0,0 +1,82 @@ +/* + * The MIT License + * + * Copyright (c) 2018, CloudBees, Inc. + * + * 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 jenkins.security.seed; + +import hudson.Extension; +import hudson.model.User; +import jenkins.security.SecurityListener; +import org.acegisecurity.userdetails.UserDetails; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.Stapler; +import org.kohsuke.stapler.StaplerRequest; + +import javax.annotation.Nonnull; +import javax.servlet.http.HttpSession; + +/** + * Inject the user seed inside the session (when there is an existing request) as part of the re-authentication mechanism + * provided by {@link hudson.security.HttpSessionContextIntegrationFilter2} and {@link UserSeedProperty}. + */ +@Restricted(NoExternalUse.class) +@Extension(ordinal = Integer.MAX_VALUE) +public class UserSeedSecurityListener extends SecurityListener { + @Override + protected void loggedIn(@Nonnull String username) { + putUserSeedInSession(username); + } + + @Override + protected void authenticated(@Nonnull UserDetails details) { + putUserSeedInSession(details.getUsername()); + } + + private void putUserSeedInSession(String username) { + StaplerRequest req = Stapler.getCurrentRequest(); + if (req == null) { + // expected case: CLI + // But also HudsonPrivateSecurityRealm because of a redirect from Acegi, the request is not a Stapler one + return; + } + + HttpSession session = req.getSession(false); + if (session == null) { + // expected case: CLI through CLIRegisterer + return; + } + + if (!UserSeedProperty.DISABLE_USER_SEED) { + User user = User.getById(username, true); + + UserSeedProperty userSeed = user.getProperty(UserSeedProperty.class); + if (userSeed == null) { + // if you want to filter out the user seed property, you should consider using the DISABLE_USER_SEED instead + return; + } + String sessionSeed = userSeed.getSeed(); + // normally invalidated before + session.setAttribute(UserSeedProperty.USER_SESSION_SEED, sessionSeed); + } + } +} diff --git a/core/src/main/resources/jenkins/security/seed/Messages.properties b/core/src/main/resources/jenkins/security/seed/Messages.properties new file mode 100644 index 0000000000..f444c16c00 --- /dev/null +++ b/core/src/main/resources/jenkins/security/seed/Messages.properties @@ -0,0 +1 @@ +UserSeedProperty.DisplayName=Session Termination diff --git a/core/src/main/resources/jenkins/security/seed/UserSeedProperty/config.jelly b/core/src/main/resources/jenkins/security/seed/UserSeedProperty/config.jelly new file mode 100644 index 0000000000..a0dcef9217 --- /dev/null +++ b/core/src/main/resources/jenkins/security/seed/UserSeedProperty/config.jelly @@ -0,0 +1,36 @@ + + + + + +
+ + + + + + + + + + + + +
${%resetSeed.result}
+
+
+
diff --git a/core/src/main/resources/jenkins/security/seed/UserSeedProperty/config.properties b/core/src/main/resources/jenkins/security/seed/UserSeedProperty/config.properties new file mode 100644 index 0000000000..24463bc071 --- /dev/null +++ b/core/src/main/resources/jenkins/security/seed/UserSeedProperty/config.properties @@ -0,0 +1,5 @@ +resetSeed.button=Terminate All Sessions +resetSeed.result=Operation success. All sessions were terminated. +resetSeed.confirmation=All connected sessions for this user will be terminated. \n\ + They will be required to log in again. \n\ + Are you sure? diff --git a/core/src/main/resources/jenkins/security/seed/UserSeedProperty/help-resetSeed.html b/core/src/main/resources/jenkins/security/seed/UserSeedProperty/help-resetSeed.html new file mode 100644 index 0000000000..becdda38d3 --- /dev/null +++ b/core/src/main/resources/jenkins/security/seed/UserSeedProperty/help-resetSeed.html @@ -0,0 +1,9 @@ +
+ This action will disconnect all connected computers and devices for this user.
+ The user will be required to log in again before performing any further operations.
+
+
+ API tokens and SSH Public Keys provide additional access mechanisms, which are not impacted by this action.
+ To revoke all access for this user also revoke these mechanisms. +
+
diff --git a/core/src/main/resources/jenkins/security/seed/UserSeedProperty/resources.css b/core/src/main/resources/jenkins/security/seed/UserSeedProperty/resources.css new file mode 100644 index 0000000000..8941a501ce --- /dev/null +++ b/core/src/main/resources/jenkins/security/seed/UserSeedProperty/resources.css @@ -0,0 +1,34 @@ +/* + * The MIT License + * + * Copyright (c) 2018, CloudBees, Inc. + * + * 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. + */ +.user-seed-panel .seed-message { + margin-bottom: 5px; +} +.user-seed-panel .display-after-reset { + display: none; +} +.user-seed-panel .display-after-reset.visible { + display: block; + margin-top: 5px; + margin-bottom: 5px; +} diff --git a/core/src/main/resources/jenkins/security/seed/UserSeedProperty/resources.js b/core/src/main/resources/jenkins/security/seed/UserSeedProperty/resources.js new file mode 100644 index 0000000000..1a6b59acd3 --- /dev/null +++ b/core/src/main/resources/jenkins/security/seed/UserSeedProperty/resources.js @@ -0,0 +1,49 @@ +/* + * The MIT License + * + * Copyright (c) 2018, CloudBees, Inc. + * + * 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. + */ +function resetSeed(button){ + var userSeedPanel = button.up('.user-seed-panel'); + var confirmMessage = button.getAttribute('data-confirm'); + var targetUrl = button.getAttribute('data-target-url'); + var redirectAfterClick = button.getAttribute('data-redirect-url'); + + var warningMessage = userSeedPanel.querySelector('.display-after-reset'); + if (warningMessage.hasClassName('visible')) { + warningMessage.removeClassName('visible'); + } + + if (confirm(confirmMessage)) { + new Ajax.Request(targetUrl, { + method: "post", + onSuccess: function(rsp, _) { + if (redirectAfterClick) { + window.location.href = redirectAfterClick; + } else { + if (!warningMessage.hasClassName('visible')) { + warningMessage.addClassName('visible'); + } + } + } + }); + } +} diff --git a/test/src/test/java/hudson/cli/ClientAuthenticationCacheWithUserSeedTest.java b/test/src/test/java/hudson/cli/ClientAuthenticationCacheWithUserSeedTest.java new file mode 100644 index 0000000000..bcdab7f844 --- /dev/null +++ b/test/src/test/java/hudson/cli/ClientAuthenticationCacheWithUserSeedTest.java @@ -0,0 +1,202 @@ +/* + * The MIT License + * + * Copyright (c) 2018, CloudBees, Inc. + * + * 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.cli; + +import com.google.common.collect.Lists; +import hudson.Launcher; +import hudson.model.User; +import hudson.security.FullControlOnceLoggedInAuthorizationStrategy; +import hudson.util.StreamTaskListener; +import jenkins.model.Jenkins; +import jenkins.security.seed.UserSeedProperty; +import org.acegisecurity.Authentication; +import org.acegisecurity.providers.UsernamePasswordAuthenticationToken; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.output.TeeOutputStream; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.jvnet.hudson.test.For; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.LoggerRule; +import test.security.realm.InMemorySecurityRealm; + +import javax.annotation.CheckForNull; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.util.Arrays; +import java.util.List; +import java.util.logging.Level; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.Matchers.containsString; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; + +@For({UserSeedProperty.class, ClientAuthenticationCache.class}) +public class ClientAuthenticationCacheWithUserSeedTest { + + @Rule + public JenkinsRule r = new JenkinsRule(); + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Rule + public LoggerRule logging = new LoggerRule().record(ClientAuthenticationCache.class, Level.FINER); + + @Test + @Issue("SECURITY-1247") + public void legacyCache_smoothlyMigratedWithUserSeed() throws Exception { + ClientAuthenticationCache cache = new ClientAuthenticationCache(null); + assertThat(cache.get(), is(Jenkins.ANONYMOUS)); + + InMemorySecurityRealm securityRealm = new InMemorySecurityRealm(); + r.jenkins.setSecurityRealm(securityRealm); + r.jenkins.setAuthorizationStrategy(new FullControlOnceLoggedInAuthorizationStrategy()); + + User user = User.getById("user", true); + + securityRealm.createAccount(user.getId()); + + try { + // legacy value + String legacyValue = user.getId() + ":" + cache.getMacOf(user.getId()); + cache.props.setProperty(cache.getPropertyKey(), legacyValue); + + cache.setUsingLegacyMethod(user.getId()); + String valueAfterSave = cache.props.getProperty(cache.getPropertyKey()); + assertThat(valueAfterSave, is(legacyValue)); + + File jar = tmp.newFile("jenkins-cli.jar"); + FileUtils.copyURLToFile(r.jenkins.getJnlpJars("jenkins-cli.jar").getURL(), jar); + + assertCLI(0, "Authenticated as: " + user.getId(), jar, "who-am-i"); + + cache = new ClientAuthenticationCache(null); + String valueAfterUsage = cache.props.getProperty(cache.getPropertyKey()); + assertThat(valueAfterUsage, is(legacyValue)); + + assertCLI(0, null, jar, "login", "--username", user.getId(), "--password", ""); + + cache = new ClientAuthenticationCache(null); + String valueAfterLogin = cache.props.getProperty(cache.getPropertyKey()); + assertThat(valueAfterLogin, not(is(legacyValue))); + + assertCLI(0, "Authenticated as: " + user.getId(), jar, "who-am-i"); + } finally { + cache.props.remove(cache.getPropertyKey()); + } + } + + @Test + @Issue("SECURITY-1247") + public void cannotUseCacheAfterUserSeedReset() throws Exception { + InMemorySecurityRealm securityRealm = new InMemorySecurityRealm(); + + File jar = tmp.newFile("jenkins-cli.jar"); + FileUtils.copyURLToFile(r.jenkins.getJnlpJars("jenkins-cli.jar").getURL(), jar); + r.jenkins.setSecurityRealm(securityRealm); + r.jenkins.setAuthorizationStrategy(new FullControlOnceLoggedInAuthorizationStrategy()); + + User user = User.getById("user", true); + + // who-am-i anonymous + assertCLI(0, "Authenticated as: anonymous", jar, "who-am-i"); + try { + // login + assertCLI(7, null, jar, "login", "--username", user.getId(), "--password", ""); + securityRealm.createAccount(user.getId()); + assertCLI(0, null, jar, "login", "--username", user.getId(), "--password", ""); + // who-am-i ok + assertCLI(0, "Authenticated as: " + user.getId(), jar, "who-am-i"); + // reset userSeed + user.getProperty(UserSeedProperty.class).renewSeed(); + // who-am-i anonymous + assertCLI(0, "Authenticated as: anonymous", jar, "who-am-i"); + // login + assertCLI(0, null, jar, "login", "--username", user.getId(), "--password", ""); + // who-am-i ok + assertCLI(0, "Authenticated as: " + user.getId(), jar, "who-am-i"); + } finally { + // to avoid letting traces in the AuthenticationCache + assertCLI(null, null, jar, "logout"); + } + } + + @Test + @Issue("SECURITY-1247") + public void canStillUseCacheAfterUserSeedReset_ifDisabled() throws Exception { + boolean previousConfig = UserSeedProperty.DISABLE_USER_SEED; + try { + UserSeedProperty.DISABLE_USER_SEED = true; + + InMemorySecurityRealm securityRealm = new InMemorySecurityRealm(); + + File jar = tmp.newFile("jenkins-cli.jar"); + FileUtils.copyURLToFile(r.jenkins.getJnlpJars("jenkins-cli.jar").getURL(), jar); + r.jenkins.setSecurityRealm(securityRealm); + r.jenkins.setAuthorizationStrategy(new FullControlOnceLoggedInAuthorizationStrategy()); + + User user = User.getById("user", true); + + // who-am-i anonymous + assertCLI(0, "Authenticated as: anonymous", jar, "who-am-i"); + + try { + // login + assertCLI(7, null, jar, "login", "--username", user.getId(), "--password", ""); + securityRealm.createAccount(user.getId()); + assertCLI(0, null, jar, "login", "--username", user.getId(), "--password", ""); + // who-am-i ok + assertCLI(0, "Authenticated as: " + user.getId(), jar, "who-am-i"); + // reset userSeed + user.getProperty(UserSeedProperty.class).renewSeed(); + // does not have any effect on the cache + assertCLI(0, "Authenticated as: " + user.getId(), jar, "who-am-i"); + } finally { + // to avoid letting traces in the AuthenticationCache + assertCLI(null, null, jar, "logout"); + } + } finally { + UserSeedProperty.DISABLE_USER_SEED = previousConfig; + } + } + + private void assertCLI(@CheckForNull Integer code, @CheckForNull String output, File jar, String... args) throws Exception { + List commands = Lists.newArrayList("java", "-jar", jar.getAbsolutePath(), "-s", r.getURL().toString(), "-noKeyAuth", "-remoting"); + commands.addAll(Arrays.asList(args)); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + int returnValue = new Launcher.LocalLauncher(StreamTaskListener.fromStderr()).launch().cmds(commands).stdout(new TeeOutputStream(System.out, baos)).stderr(System.err).join(); + if (code != null) { + assertEquals(code.intValue(), returnValue); + } + if (output != null) { + assertThat(baos.toString(), containsString(output)); + } + } +} diff --git a/test/src/test/java/hudson/security/HudsonPrivateSecurityRealmSEC1245Test.java b/test/src/test/java/hudson/security/HudsonPrivateSecurityRealmSEC1245Test.java new file mode 100644 index 0000000000..1f2eece510 --- /dev/null +++ b/test/src/test/java/hudson/security/HudsonPrivateSecurityRealmSEC1245Test.java @@ -0,0 +1,162 @@ +/* + * The MIT License + * + * Copyright (c) 2015, CloudBees, Inc. and others + * + * 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 com.gargoylesoftware.htmlunit.html.HtmlForm; +import com.gargoylesoftware.htmlunit.html.HtmlPage; +import com.gargoylesoftware.htmlunit.html.HtmlPasswordInput; +import com.gargoylesoftware.htmlunit.xml.XmlPage; +import hudson.model.User; +import jenkins.security.seed.UserSeedProperty; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.For; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.JenkinsRule.WebClient; + +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.xml.HasXPath.hasXPath; +import static org.junit.Assert.assertThat; + +@For({UserSeedProperty.class, HudsonPrivateSecurityRealm.class}) +public class HudsonPrivateSecurityRealmSEC1245Test { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Test + @Issue("SECURITY-1245") + public void changingPassword_mustInvalidateAllSessions() throws Exception { + User alice = prepareRealmAndAlice(); + String initialSeed = alice.getProperty(UserSeedProperty.class).getSeed(); + + WebClient wc = j.createWebClient(); + WebClient wc_anotherTab = j.createWebClient(); + + wc.login(alice.getId()); + assertUserConnected(wc, alice.getId()); + + wc_anotherTab.login(alice.getId()); + assertUserConnected(wc_anotherTab, alice.getId()); + + HtmlPage configurePage = wc.goTo(alice.getUrl() + "/configure"); + HtmlPasswordInput password1 = configurePage.getElementByName("user.password"); + HtmlPasswordInput password2 = configurePage.getElementByName("user.password2"); + + password1.setText("alice2"); + password2.setText("alice2"); + + HtmlForm form = configurePage.getFormByName("config"); + j.submit(form); + + assertUserNotConnected(wc, alice.getId()); + assertUserNotConnected(wc_anotherTab, alice.getId()); + + String seedAfter = alice.getProperty(UserSeedProperty.class).getSeed(); + assertThat(seedAfter, not(is(initialSeed))); + } + + @Test + @Issue("SECURITY-1245") + public void notChangingPassword_hasNoImpactOnSeed() throws Exception { + User alice = prepareRealmAndAlice(); + String initialSeed = alice.getProperty(UserSeedProperty.class).getSeed(); + + WebClient wc = j.createWebClient(); + WebClient wc_anotherTab = j.createWebClient(); + + wc.login(alice.getId()); + assertUserConnected(wc, alice.getId()); + + wc_anotherTab.login(alice.getId()); + assertUserConnected(wc_anotherTab, alice.getId()); + + HtmlPage configurePage = wc.goTo(alice.getUrl() + "/configure"); + // not changing password this time + HtmlForm form = configurePage.getFormByName("config"); + j.submit(form); + + assertUserConnected(wc, alice.getId()); + assertUserConnected(wc_anotherTab, alice.getId()); + + String seedAfter = alice.getProperty(UserSeedProperty.class).getSeed(); + assertThat(seedAfter, is(initialSeed)); + } + + @Test + @Issue("SECURITY-1245") + public void changingPassword_withSeedDisable_hasNoImpact() throws Exception { + boolean previousConfig = UserSeedProperty.DISABLE_USER_SEED; + try { + UserSeedProperty.DISABLE_USER_SEED = true; + + User alice = prepareRealmAndAlice(); + + WebClient wc = j.createWebClient(); + WebClient wc_anotherTab = j.createWebClient(); + + wc.login(alice.getId()); + assertUserConnected(wc, alice.getId()); + + wc_anotherTab.login(alice.getId()); + assertUserConnected(wc_anotherTab, alice.getId()); + + HtmlPage configurePage = wc.goTo(alice.getUrl() + "/configure"); + HtmlPasswordInput password1 = configurePage.getElementByName("user.password"); + HtmlPasswordInput password2 = configurePage.getElementByName("user.password2"); + + password1.setText("alice2"); + password2.setText("alice2"); + + HtmlForm form = configurePage.getFormByName("config"); + j.submit(form); + + assertUserConnected(wc, alice.getId()); + assertUserConnected(wc_anotherTab, alice.getId()); + } finally { + UserSeedProperty.DISABLE_USER_SEED = previousConfig; + } + } + + private User prepareRealmAndAlice() throws Exception { + j.jenkins.setDisableRememberMe(false); + HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(false, false, null); + j.jenkins.setSecurityRealm(securityRealm); + + return securityRealm.createAccount("alice", "alice"); + } + + private void assertUserConnected(JenkinsRule.WebClient wc, String expectedUsername) throws Exception { + XmlPage page = (XmlPage) wc.goTo("whoAmI/api/xml", "application/xml"); + assertThat(page, hasXPath("//name", is(expectedUsername))); + } + + private void assertUserNotConnected(JenkinsRule.WebClient wc, String notExpectedUsername) throws Exception { + XmlPage page = (XmlPage) wc.goTo("whoAmI/api/xml", "application/xml"); + assertThat(page, hasXPath("//name", not(is(notExpectedUsername)))); + } +} diff --git a/test/src/test/java/hudson/security/TokenBasedRememberMeServices2SEC868Test.java b/test/src/test/java/hudson/security/TokenBasedRememberMeServices2SEC868Test.java new file mode 100644 index 0000000000..8f8031b79b --- /dev/null +++ b/test/src/test/java/hudson/security/TokenBasedRememberMeServices2SEC868Test.java @@ -0,0 +1,217 @@ +/* + * The MIT License + * + * Copyright (c) 2018, CloudBees, Inc. + * + * 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 com.gargoylesoftware.htmlunit.CookieManager; +import com.gargoylesoftware.htmlunit.util.Cookie; +import com.gargoylesoftware.htmlunit.xml.XmlPage; +import hudson.model.User; +import jenkins.security.seed.UserSeedProperty; +import org.apache.commons.codec.binary.Base64; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.For; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; + +import java.util.concurrent.TimeUnit; + +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.xml.HasXPath.hasXPath; +import static org.junit.Assert.assertThat; + +@For(TokenBasedRememberMeServices2.class) +public class TokenBasedRememberMeServices2SEC868Test { + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Test + @Issue("SECURITY-868") + @For(UserSeedProperty.class) + public void rememberMeToken_invalid_afterUserSeedReset() throws Exception { + j.jenkins.setDisableRememberMe(false); + + HudsonPrivateSecurityRealm realm = new HudsonPrivateSecurityRealm(false, false, null); + j.jenkins.setSecurityRealm(realm); + + String username = "alice"; + User alice = realm.createAccount(username, username); + + JenkinsRule.WebClient wc = j.createWebClient(); + + wc.login(username, username, true); + CookieManager cm = wc.getCookieManager(); + + cm.removeCookie(cm.getCookie("JSESSIONID")); + assertUserConnected(wc, username); + + alice.getProperty(UserSeedProperty.class).renewSeed(); + + cm.removeCookie(cm.getCookie("JSESSIONID")); + assertUserNotConnected(wc, username); + } + + @Test + @Issue("SECURITY-868") + @For(UserSeedProperty.class) + public void rememberMeToken_stillValid_afterUserSeedReset_ifUserSeedDisabled() throws Exception { + boolean currentStatus = UserSeedProperty.DISABLE_USER_SEED; + try { + UserSeedProperty.DISABLE_USER_SEED = true; + + j.jenkins.setDisableRememberMe(false); + + HudsonPrivateSecurityRealm realm = new HudsonPrivateSecurityRealm(false, false, null); + j.jenkins.setSecurityRealm(realm); + + String username = "alice"; + User alice = realm.createAccount(username, username); + + JenkinsRule.WebClient wc = j.createWebClient(); + + wc.login(username, username, true); + CookieManager cm = wc.getCookieManager(); + + cm.removeCookie(cm.getCookie("JSESSIONID")); + assertUserConnected(wc, username); + + alice.getProperty(UserSeedProperty.class).renewSeed(); + + cm.removeCookie(cm.getCookie("JSESSIONID")); + // as userSeed disabled, no care about the renew + assertUserConnected(wc, username); + } finally { + UserSeedProperty.DISABLE_USER_SEED = currentStatus; + } + } + + @Test + @Issue("SECURITY-868") + public void rememberMeToken_shouldNotAccept_expirationDurationLargerThanConfigured() throws Exception { + j.jenkins.setDisableRememberMe(false); + + HudsonPrivateSecurityRealm realm = new HudsonPrivateSecurityRealm(false, false, null); + TokenBasedRememberMeServices2 tokenService = (TokenBasedRememberMeServices2) realm.getSecurityComponents().rememberMe; + j.jenkins.setSecurityRealm(realm); + + String username = "alice"; + User alice = realm.createAccount(username, username); + + { // a malicious cookie with expiration too far in the future should not work + JenkinsRule.WebClient wc = j.createWebClient(); + + // by default we have 14 days of validity, + // here we increase artificially the duration of validity, that could be used to have permanent access + long oneDay = TimeUnit.DAYS.toMillis(1); + Cookie cookie = createRememberMeCookie(tokenService, oneDay, alice); + wc.getCookieManager().addCookie(cookie); + + // the application should not use the cookie to connect + assertUserNotConnected(wc, username); + } + + { // a hand crafted cookie with regular expiration duration works + JenkinsRule.WebClient wc = j.createWebClient(); + + // by default we have 14 days of validity, + // here we reduce a bit the expiration date to simulate an "old" cookie (regular usage) + long minusFiveMinutes = TimeUnit.MINUTES.toMillis(-5); + Cookie cookie = createRememberMeCookie(tokenService, minusFiveMinutes, alice); + wc.getCookieManager().addCookie(cookie); + + // if we reactivate the remember me feature, it's ok + assertUserConnected(wc, username); + } + } + + @Test + @Issue("SECURITY-868") + public void rememberMeToken_skipExpirationCheck() throws Exception { + boolean previousConfig = TokenBasedRememberMeServices2.SKIP_TOO_FAR_EXPIRATION_DATE_CHECK; + try { + TokenBasedRememberMeServices2.SKIP_TOO_FAR_EXPIRATION_DATE_CHECK = true; + + j.jenkins.setDisableRememberMe(false); + + HudsonPrivateSecurityRealm realm = new HudsonPrivateSecurityRealm(false, false, null); + TokenBasedRememberMeServices2 tokenService = (TokenBasedRememberMeServices2) realm.getSecurityComponents().rememberMe; + j.jenkins.setSecurityRealm(realm); + + String username = "alice"; + User alice = realm.createAccount(username, username); + + { // a malicious cookie with expiration too far in the future should not work + JenkinsRule.WebClient wc = j.createWebClient(); + + // by default we have 14 days of validity, + // here we increase artificially the duration of validity, that could be used to have permanent access + long oneDay = TimeUnit.DAYS.toMillis(1); + Cookie cookie = createRememberMeCookie(tokenService, oneDay, alice); + wc.getCookieManager().addCookie(cookie); + + // the application should not use the cookie to connect + assertUserConnected(wc, username); + } + + { // a hand crafted cookie with regular expiration duration works + JenkinsRule.WebClient wc = j.createWebClient(); + + // by default we have 14 days of validity, + // here we reduce a bit the expiration date to simulate an "old" cookie (regular usage) + long minusFiveMinutes = TimeUnit.MINUTES.toMillis(-5); + Cookie cookie = createRememberMeCookie(tokenService, minusFiveMinutes, alice); + wc.getCookieManager().addCookie(cookie); + + // if we reactivate the remember me feature, it's ok + assertUserConnected(wc, username); + } + } finally { + TokenBasedRememberMeServices2.SKIP_TOO_FAR_EXPIRATION_DATE_CHECK = previousConfig; + } + } + + private Cookie createRememberMeCookie(TokenBasedRememberMeServices2 tokenService, long deltaDuration, User user) throws Exception { + long tokenValiditySeconds = tokenService.getTokenValiditySeconds(); + long expiryTime = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(tokenValiditySeconds); + + // the hack + expiryTime += deltaDuration; + + String signatureValue = tokenService.makeTokenSignature(expiryTime, user.getProperty(HudsonPrivateSecurityRealm.Details.class)); + String tokenValue = user.getId() + ":" + expiryTime + ":" + signatureValue; + String tokenValueBase64 = new String(Base64.encodeBase64(tokenValue.getBytes())); + return new Cookie(j.getURL().getHost(), tokenService.getCookieName(), tokenValueBase64); + } + + private void assertUserConnected(JenkinsRule.WebClient wc, String expectedUsername) throws Exception { + XmlPage page = (XmlPage) wc.goTo("whoAmI/api/xml", "application/xml"); + assertThat(page, hasXPath("//name", is(expectedUsername))); + } + + private void assertUserNotConnected(JenkinsRule.WebClient wc, String notExpectedUsername) throws Exception { + XmlPage page = (XmlPage) wc.goTo("whoAmI/api/xml", "application/xml"); + assertThat(page, hasXPath("//name", not(is(notExpectedUsername)))); + } +} diff --git a/test/src/test/java/jenkins/security/seed/UserSeedChangeListenerTest.java b/test/src/test/java/jenkins/security/seed/UserSeedChangeListenerTest.java new file mode 100644 index 0000000000..23d6e3a244 --- /dev/null +++ b/test/src/test/java/jenkins/security/seed/UserSeedChangeListenerTest.java @@ -0,0 +1,102 @@ +/* + * The MIT License + * + * Copyright (c) 2018 CloudBees, Inc. + * + * 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 jenkins.security.seed; + +import com.gargoylesoftware.htmlunit.HttpMethod; +import com.gargoylesoftware.htmlunit.WebRequest; +import hudson.model.User; +import org.hamcrest.CoreMatchers; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.TestExtension; + +import javax.annotation.Nonnull; + +import java.net.URL; +import java.util.Random; + +import static org.hamcrest.CoreMatchers.is; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThat; + +public class UserSeedChangeListenerTest { + + @Rule + public JenkinsRule j = new JenkinsRule(); + {j.timeout = 0;} + + @Test + public void onProgrammaticUserSeedChange_listenerTriggered() throws Exception { + TestUserSeedChangeListener testListener = j.jenkins.getExtensionList(UserSeedChangeListener.class).get(TestUserSeedChangeListener.class); + + String userId = "alice"; + User alice = User.getById(userId, true); + assertNull(testListener.lastUserIdReceived); + + UserSeedProperty userSeed = alice.getProperty(UserSeedProperty.class); + assertNull(testListener.lastUserIdReceived); + + userSeed.renewSeed(); + assertThat(testListener.lastUserIdReceived, is(userId)); + assertThat(testListener.userWasNull, is(false)); + } + + @Test + public void onWebCallUserSeedChange_listenerTriggered() throws Exception { + j.jenkins.setCrumbIssuer(null); + + TestUserSeedChangeListener testListener = j.jenkins.getExtensionList(UserSeedChangeListener.class).get(TestUserSeedChangeListener.class); + + String userId = "alice"; + User alice = User.getById(userId, true); + UserSeedProperty userSeed = alice.getProperty(UserSeedProperty.class); + + JenkinsRule.WebClient wc = j.createWebClient(); + WebRequest webRequest = new WebRequest( + new URL(j.getURL() + alice.getUrl() + "/" + userSeed.getDescriptor().getDescriptorUrl() + "/renewSessionSeed"), + HttpMethod.POST + ); + + assertNull(testListener.lastUserIdReceived); + wc.getPage(webRequest); + assertThat(testListener.lastUserIdReceived, is(userId)); + assertThat(testListener.userWasNull, is(false)); + } + + @TestExtension + public static class TestUserSeedChangeListener extends UserSeedChangeListener { + String lastUserIdReceived; + boolean userWasNull; + + @Override + public void onUserSeedRenewed(@Nonnull User user) { + if (user == null) { + userWasNull = true; + } + lastUserIdReceived = user.getId(); + } + } +} diff --git a/test/src/test/java/jenkins/security/seed/UserSeedPropertyRestartTest.java b/test/src/test/java/jenkins/security/seed/UserSeedPropertyRestartTest.java new file mode 100644 index 0000000000..5534257dab --- /dev/null +++ b/test/src/test/java/jenkins/security/seed/UserSeedPropertyRestartTest.java @@ -0,0 +1,114 @@ +/* + * The MIT License + * + * Copyright (c) 2018 CloudBees, Inc. + * + * 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 jenkins.security.seed; + +import com.gargoylesoftware.htmlunit.HttpMethod; +import com.gargoylesoftware.htmlunit.WebRequest; +import hudson.model.User; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runners.model.Statement; +import org.jvnet.hudson.test.For; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.RestartableJenkinsRule; + +import java.net.URL; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; + +@For(UserSeedProperty.class) +public class UserSeedPropertyRestartTest { + + @Rule + public RestartableJenkinsRule rr = new RestartableJenkinsRule(); + + @Test + @Issue("SECURITY-901") + public void initialSeedIsSaved() throws Exception { + AtomicReference initialSeedRef = new AtomicReference<>(); + + rr.addStep(new Statement() { + @Override + public void evaluate() throws Throwable { + rr.j.jenkins.setCrumbIssuer(null); + rr.j.jenkins.save(); + + User alice = User.getById("alice", true); + alice.save(); + initialSeedRef.set(alice.getProperty(UserSeedProperty.class).getSeed()); + } + }); + rr.addStep(new Statement() { + @Override + public void evaluate() throws Throwable { + User alice = User.getById("alice", false); + String initialSeed = alice.getProperty(UserSeedProperty.class).getSeed(); + assertEquals(initialSeed, initialSeedRef.get()); + } + }); + } + + @Test + @Issue("SECURITY-901") + public void renewSeedSavesTheChange() throws Exception { + AtomicReference initialSeedRef = new AtomicReference<>(); + AtomicReference seedRef = new AtomicReference<>(); + + rr.addStep(new Statement() { + @Override + public void evaluate() throws Throwable { + rr.j.jenkins.setCrumbIssuer(null); + rr.j.jenkins.save(); + + User alice = User.getById("alice", true); + alice.save(); + initialSeedRef.set(alice.getProperty(UserSeedProperty.class).getSeed()); + + requestRenewSeedForUser(alice); + + seedRef.set(alice.getProperty(UserSeedProperty.class).getSeed()); + assertNotEquals(initialSeedRef.get(), seedRef.get()); + } + }); + rr.addStep(new Statement() { + @Override + public void evaluate() throws Throwable { + User alice = User.getById("alice", false); + assertNotNull(alice); + String currentSeed = alice.getProperty(UserSeedProperty.class).getSeed(); + assertEquals(currentSeed, seedRef.get()); + } + }); + } + + private void requestRenewSeedForUser(User user) throws Exception { + JenkinsRule.WebClient wc = rr.j.createWebClient(); + WebRequest request = new WebRequest(new URL(rr.j.jenkins.getRootUrl() + user.getUrl() + "/descriptorByName/" + UserSeedProperty.class.getName() + "/renewSessionSeed/"), HttpMethod.POST); + wc.getPage(request); + } +} diff --git a/test/src/test/java/jenkins/security/seed/UserSeedPropertyTest.java b/test/src/test/java/jenkins/security/seed/UserSeedPropertyTest.java new file mode 100644 index 0000000000..33e5e5debd --- /dev/null +++ b/test/src/test/java/jenkins/security/seed/UserSeedPropertyTest.java @@ -0,0 +1,324 @@ +/* + * The MIT License + * + * Copyright (c) 2018 CloudBees, Inc. + * + * 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 jenkins.security.seed; + +import com.gargoylesoftware.htmlunit.ElementNotFoundException; +import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException; +import com.gargoylesoftware.htmlunit.HttpMethod; +import com.gargoylesoftware.htmlunit.WebRequest; +import com.gargoylesoftware.htmlunit.html.HtmlElement; +import com.gargoylesoftware.htmlunit.html.HtmlPage; +import com.gargoylesoftware.htmlunit.xml.XmlPage; +import hudson.model.User; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; +import test.security.realm.InMemorySecurityRealm; + +import java.lang.reflect.Method; +import java.net.URL; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.xml.HasXPath.hasXPath; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.fail; + +public class UserSeedPropertyTest { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Test + @Issue("SECURITY-901") + public void userCreation_implies_userSeedCreation() throws Exception { + User alice = User.getById("alice", true); + assertNotNull(alice); + UserSeedProperty userSeed = alice.getProperty(UserSeedProperty.class); + assertNotNull(userSeed); + assertNotNull(userSeed.getSeed()); + } + + @Test + @Issue("SECURITY-901") + public void userSeedRenewal_changeTheSeed() throws Exception { + j.jenkins.setCrumbIssuer(null); + Set seeds = new HashSet<>(); + + User alice = User.getById("alice", true); + + UserSeedProperty userSeed = alice.getProperty(UserSeedProperty.class); + seeds.add(userSeed.getSeed()); + + int times = 10; + for (int i = 1; i < times; i++) { + requestRenewSeedForUser(alice); + userSeed = alice.getProperty(UserSeedProperty.class); + seeds.add(userSeed.getSeed()); + } + + assertThat(seeds.size(), equalTo(times)); + assertFalse(seeds.contains("")); + assertFalse(seeds.contains(null)); + } + + @Test + @Issue("SECURITY-901") + public void initialUserSeedIsAlwaysDifferent() throws Exception { + Set seeds = new HashSet<>(); + + int times = 10; + for (int i = 0; i < times; i++) { + User alice = User.getById("alice", true); + UserSeedProperty userSeed = alice.getProperty(UserSeedProperty.class); + seeds.add(userSeed.getSeed()); + alice.delete(); + } + + assertThat(seeds.size(), equalTo(times)); + assertFalse(seeds.contains("")); + assertFalse(seeds.contains(null)); + } + + @Test + @Issue("SECURITY-901") + public void differentUserHaveDifferentInitialSeeds() throws Exception { + Set seeds = new HashSet<>(); + + List userIds = Arrays.asList("Alice", "Bob", "Charles", "Derek", "Edward"); + userIds.forEach(userId -> { + User user = User.getById(userId, true); + UserSeedProperty userSeed = user.getProperty(UserSeedProperty.class); + seeds.add(userSeed.getSeed()); + }); + + assertThat(seeds.size(), equalTo(userIds.size())); + assertFalse(seeds.contains("")); + assertFalse(seeds.contains(null)); + } + + @Test + @Issue("SECURITY-901") + public void userCreatedInThirdPartyRealm_cannotReconnect_afterSessionInvalidation_andRealmDeletion() throws Exception { + InMemorySecurityRealm realm = new InMemorySecurityRealm(); + j.jenkins.setSecurityRealm(realm); + j.jenkins.setCrumbIssuer(null); + + String ALICE = "alice"; + + realm.createAccount(ALICE); + + JenkinsRule.WebClient wc = j.createWebClient(); + wc.login(ALICE); + + User alice = User.getById(ALICE, false); + assertNotNull(alice); + UserSeedProperty userSeed = alice.getProperty(UserSeedProperty.class); + assertNotNull(userSeed); + + assertUserConnected(wc, ALICE); + + realm.deleteAccount(ALICE); + + // even after the security realm deleted the user, they can still connect, until session invalidation + assertUserConnected(wc, ALICE); + + requestRenewSeedForUser(alice); + + assertUserNotConnected(wc, ALICE); + assertUserConnected(wc, "anonymous"); + + try { + wc.login(ALICE); + fail("Alice does not exist any longer and so should not be able to login"); + } catch (FailingHttpStatusCodeException e) { + assertEquals(401, e.getStatusCode()); + } + } + + @Test + @Issue("SECURITY-901") + public void userAfterBeingDeletedInThirdPartyRealm_canStillUseTheirSession_withDisabledSeed() throws Exception { + boolean currentStatus = UserSeedProperty.DISABLE_USER_SEED; + try { + UserSeedProperty.DISABLE_USER_SEED = true; + + InMemorySecurityRealm realm = new InMemorySecurityRealm(); + j.jenkins.setSecurityRealm(realm); + j.jenkins.setCrumbIssuer(null); + + String ALICE = "alice"; + + realm.createAccount(ALICE); + + JenkinsRule.WebClient wc = j.createWebClient(); + wc.login(ALICE); + + User alice = User.getById(ALICE, false); + assertNotNull(alice); + UserSeedProperty userSeed = alice.getProperty(UserSeedProperty.class); + assertNotNull(userSeed); + + assertUserConnected(wc, ALICE); + + realm.deleteAccount(ALICE); + + // even after the security realm deleted the user, they can still connect, until session invalidation + assertUserConnected(wc, ALICE); + + try { + requestRenewSeedForUser(alice); + fail("The feature should be disabled"); + } catch (FailingHttpStatusCodeException e) { + // as the feature is disabled, we cannot renew the seed + } + + // failed attempt to renew the seed does not have any effect + assertUserConnected(wc, ALICE); + + UserSeedProperty userSeedProperty = alice.getProperty(UserSeedProperty.class); + userSeedProperty.renewSeed(); + + // failed attempt to renew the seed does not have any effect + assertUserConnected(wc, ALICE); + + JenkinsRule.WebClient wc2 = j.createWebClient(); + try { + wc2.login(ALICE); + fail("Alice is not longer backed by security realm"); + } catch (FailingHttpStatusCodeException e) { + assertEquals(401, e.getStatusCode()); + } + } finally { + UserSeedProperty.DISABLE_USER_SEED = currentStatus; + } + } + + @Test + @Issue("SECURITY-901") + public void userCreatedInThirdPartyRealm_canReconnect_afterSessionInvalidation() throws Exception { + InMemorySecurityRealm realm = new InMemorySecurityRealm(); + j.jenkins.setSecurityRealm(realm); + j.jenkins.setCrumbIssuer(null); + + String ALICE = "alice"; + + realm.createAccount(ALICE); + + JenkinsRule.WebClient wc = j.createWebClient(); + wc.login(ALICE); + + User alice = User.getById(ALICE, false); + assertNotNull(alice); + UserSeedProperty userSeed = alice.getProperty(UserSeedProperty.class); + assertNotNull(userSeed); + + assertUserConnected(wc, ALICE); + + requestRenewSeedForUser(alice); + + assertUserNotConnected(wc, ALICE); + assertUserConnected(wc, "anonymous"); + + wc.login(ALICE); + assertUserConnected(wc, ALICE); + } + + @Test + public void userSeedSection_isCorrectlyDisplayed() throws Exception { + InMemorySecurityRealm realm = new InMemorySecurityRealm(); + j.jenkins.setSecurityRealm(realm); + j.jenkins.setCrumbIssuer(null); + + String ALICE = "alice"; + + realm.createAccount(ALICE); + + JenkinsRule.WebClient wc = j.createWebClient(); + wc.login(ALICE); + + User alice = User.getById(ALICE, false); + assertNotNull(alice); + + HtmlPage htmlPage = wc.goTo(alice.getUrl() + "/configure"); + htmlPage.getDocumentElement().getOneHtmlElementByAttribute("div", "class", "user-seed-panel"); + } + + @Test + public void userSeedSection_isCorrectlyHidden_withSpecificSetting() throws Exception { + boolean currentStatus = UserSeedProperty.HIDE_USER_SEED_SECTION; + try { + UserSeedProperty.HIDE_USER_SEED_SECTION = true; + + InMemorySecurityRealm realm = new InMemorySecurityRealm(); + j.jenkins.setSecurityRealm(realm); + j.jenkins.setCrumbIssuer(null); + + String ALICE = "alice"; + + realm.createAccount(ALICE); + + JenkinsRule.WebClient wc = j.createWebClient(); + wc.login(ALICE); + + User alice = User.getById(ALICE, false); + assertNotNull(alice); + + HtmlPage htmlPage = wc.goTo(alice.getUrl() + "/configure"); + try { + htmlPage.getDocumentElement().getOneHtmlElementByAttribute("div", "class", "user-seed-panel"); + fail("Seed section should not be displayed"); + } + catch (ElementNotFoundException e) {} + } + finally { + UserSeedProperty.HIDE_USER_SEED_SECTION = currentStatus; + } + } + + private void assertUserConnected(JenkinsRule.WebClient wc, String expectedUsername) throws Exception { + XmlPage page = (XmlPage) wc.goTo("whoAmI/api/xml", "application/xml"); + assertThat(page, hasXPath("//name", is(expectedUsername))); + } + + private void assertUserNotConnected(JenkinsRule.WebClient wc, String notExpectedUsername) throws Exception { + XmlPage page = (XmlPage) wc.goTo("whoAmI/api/xml", "application/xml"); + assertThat(page, hasXPath("//name", not(is(notExpectedUsername)))); + } + + private void requestRenewSeedForUser(User user) throws Exception { + JenkinsRule.WebClient wc = j.createWebClient(); + WebRequest request = new WebRequest(new URL(j.jenkins.getRootUrl() + user.getUrl() + "/descriptorByName/" + UserSeedProperty.class.getName() + "/renewSessionSeed/"), HttpMethod.POST); + wc.getPage(request); + } +} diff --git a/test/src/test/java/test/README.md b/test/src/test/java/test/README.md new file mode 100644 index 0000000000..dd0cd5734a --- /dev/null +++ b/test/src/test/java/test/README.md @@ -0,0 +1,3 @@ +Package meant to be used to collect all the useful classes that are not tests, but used in test. + +Improve readability by splitting test cases and test classes. diff --git a/test/src/test/java/test/security/realm/InMemorySecurityRealm.java b/test/src/test/java/test/security/realm/InMemorySecurityRealm.java new file mode 100644 index 0000000000..879439ed8d --- /dev/null +++ b/test/src/test/java/test/security/realm/InMemorySecurityRealm.java @@ -0,0 +1,119 @@ +/* + * The MIT License + * + * Copyright (c) 2018, CloudBees, Inc. + * + * 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 test.security.realm; + +import hudson.security.AbstractPasswordBasedSecurityRealm; +import hudson.security.GroupDetails; +import org.acegisecurity.AuthenticationException; +import org.acegisecurity.GrantedAuthority; +import org.acegisecurity.userdetails.UserDetails; +import org.acegisecurity.userdetails.UsernameNotFoundException; +import org.springframework.dao.DataAccessException; + +import java.util.HashMap; +import java.util.Map; + +/** + * Accept any password + * Allow creation and removal of users + */ +public class InMemorySecurityRealm extends AbstractPasswordBasedSecurityRealm { + private Map userStorage = new HashMap<>(); + + public synchronized void createAccount(String username) { + if (userStorage.containsKey(username)) { + return; + } + UserDetails details = new InMemoryUserDetails(username); + userStorage.put(username, details); + } + + public synchronized void deleteAccount(String username) { + if (!userStorage.containsKey(username)) { + return; + } + userStorage.remove(username); + } + + @Override + protected UserDetails authenticate(String username, String password) throws AuthenticationException { + if (userStorage.containsKey(username)) { + return userStorage.get(username); + } + throw new UsernameNotFoundException("Unknown user: " + username); + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException, DataAccessException { + return userStorage.get(username); + } + + @Override + public GroupDetails loadGroupByGroupname(String groupname) throws UsernameNotFoundException, DataAccessException { + return null; + } + + private static class InMemoryUserDetails implements UserDetails { + private final String username; + + private InMemoryUserDetails(String username) { + this.username = username; + } + + @Override + public GrantedAuthority[] getAuthorities() { + return new GrantedAuthority[0]; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getPassword() { + return null; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + } +}