Merge branch 'security-stable-2.121' into security-stable-2.138

This commit is contained in:
Jeff Thompson committed 2019-01-03 10:03:43 -07:00
commit 18b40501ac
25 files changed
+2105 -25

No files matched your search

+15 -1
View File
@@ -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> T getNearestAncestorOfTypeOrThrow(@Nonnull StaplerRequest request, @Nonnull Class<T> 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!
@@ -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.
*/
+2 -1
View File
@@ -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<Ancestor> l = req.getAncestors();
for( int i=l.size()-1; i>=0; i-- ) {
Ancestor a = l.get(i);
@@ -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());
}
@@ -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;
}
}
@@ -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));
}
@@ -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.
*/
@@ -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)
@@ -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<UserSeedChangeListener> all() {
return ExtensionList.lookup(UserSeedChangeListener.class);
}
}
@@ -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;
}
}
}
@@ -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);
}
}
}
@@ -0,0 +1 @@
UserSeedProperty.DisplayName=Session Termination
@@ -0,0 +1,36 @@
<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:f="/lib/form">
<st:adjunct includes="jenkins.security.seed.UserSeedProperty.resources" />
<f:entry field="resetSeed">
<!--
name=OK is a hack until 2.129 where the "ignore-dirty-panel" is added,
otherwise the "there are some modifications on the page" will appear after redirection
-->
<div class="user-seed-panel" name="OK">
<j:choose>
<j:when test="${descriptor.isCurrentUser(it)}">
<span class="yui-button">
<button type="button" name="reset-seed"
onclick="return resetSeed(this)"
data-target-url="${descriptor.descriptorFullUrl}/renewSessionSeed"
data-confirm="${%resetSeed.confirmation}"
data-redirect-url="${request.contextPath}/">
${%resetSeed.button}
</button>
</span>
</j:when>
<j:otherwise>
<span class="yui-button">
<button type="button" name="reset-seed"
onclick="return resetSeed(this)"
data-target-url="${descriptor.descriptorFullUrl}/renewSessionSeed"
data-confirm="${%resetSeed.confirmation}">
${%resetSeed.button}
</button>
</span>
</j:otherwise>
</j:choose>
<div class="display-after-reset">${%resetSeed.result}</div>
</div>
</f:entry>
</j:jelly>
@@ -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?
@@ -0,0 +1,9 @@
<div>
This action will disconnect all connected computers and devices for this user. <br />
The user will be required to log in again before performing any further operations. <br />
<br />
<div class="warning">
API tokens and SSH Public Keys provide additional access mechanisms, which are not impacted by this action. <br />
To revoke all access for this user also revoke these mechanisms.
</div>
</div>
@@ -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;
}
@@ -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');
}
}
}
});
}
}
@@ -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", "<anyPwdForThatRealm>");
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", "<anyPwdForThatRealm>");
securityRealm.createAccount(user.getId());
assertCLI(0, null, jar, "login", "--username", user.getId(), "--password", "<anyPwdForThatRealm>");
// 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", "<anyPwdForThatRealm>");
// 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", "<anyPwdForThatRealm>");
securityRealm.createAccount(user.getId());
assertCLI(0, null, jar, "login", "--username", user.getId(), "--password", "<anyPwdForThatRealm>");
// 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<String> 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));
}
}
}
@@ -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))));
}
}
@@ -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))));
}
}
@@ -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();
}
}
}
@@ -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<String> 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<String> initialSeedRef = new AtomicReference<>();
AtomicReference<String> 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);
}
}
@@ -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<String> 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<String> 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<String> seeds = new HashSet<>();
List<String> 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);
}
}
+3
View File
@@ -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.
@@ -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<String, UserDetails> 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;
}
}
}