mirror of
https://github.com/jenkinsci/jenkins.git
synced 2026-11-04 23:55:52 +00:00
Merge branch 'security-stable-2.121' into security-stable-2.138
This commit is contained in:
commit
18b40501ac
25 files changed
+2105
-25
No files matched your search
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user