diff --git a/changelog.html b/changelog.html index e732f5ba65f9d47127080c68a57c91b4577e23bc..59d5732d30c361d807f8810ac191d3ee9606b8fc 100644 --- a/changelog.html +++ b/changelog.html @@ -55,7 +55,9 @@ Upcoming changes diff --git a/core/src/main/java/hudson/model/User.java b/core/src/main/java/hudson/model/User.java index cd942156001a8c9be831ea5dbef87e222745e83c..7cd3cc635e15351547f5f270e0f7686b29e6e768 100644 --- a/core/src/main/java/hudson/model/User.java +++ b/core/src/main/java/hudson/model/User.java @@ -33,11 +33,13 @@ import hudson.security.ACL; import hudson.security.AccessControlled; import hudson.security.Permission; import hudson.security.SecurityRealm; +import hudson.security.UserMayOrMayNotExistException; import hudson.util.FormApply; import hudson.util.RunList; import hudson.util.XStream2; import jenkins.model.Jenkins; import jenkins.model.ModelObjectWithContextMenu; +import jenkins.security.LastGrantedAuthoritiesProperty; import net.sf.json.JSONObject; import org.acegisecurity.Authentication; @@ -255,14 +257,21 @@ public class User extends AbstractModelObject implements AccessControlled, Descr try { UserDetails u = Jenkins.getInstance().getSecurityRealm().loadUserByUsername(id); return new UsernamePasswordAuthenticationToken(u.getUsername(), "", u.getAuthorities()); + } catch (UserMayOrMayNotExistException e) { + // backend can't load information about other users. so use the stored information if available } catch (UsernameNotFoundException e) { - // ignore + // if the user no longer exists in the backend, we need to refuse impersonating this user + throw e; } catch (DataAccessException e) { - // ignore + // seems like it's in the same boat as UserMayOrMayNotExistException } - // TODO: use the stored GrantedAuthorities - return new UsernamePasswordAuthenticationToken(id, "", - new GrantedAuthority[]{SecurityRealm.AUTHENTICATED_AUTHORITY}); + + LastGrantedAuthoritiesProperty p = getProperty(LastGrantedAuthoritiesProperty.class); + if (p!=null) + return new UsernamePasswordAuthenticationToken(id, "", p.getAuthorities()); + else + return new UsernamePasswordAuthenticationToken(id, "", + new GrantedAuthority[]{SecurityRealm.AUTHENTICATED_AUTHORITY}); } /** diff --git a/core/src/main/java/jenkins/security/LastGrantedAuthoritiesProperty.java b/core/src/main/java/jenkins/security/LastGrantedAuthoritiesProperty.java new file mode 100644 index 0000000000000000000000000000000000000000..834fbd657ecdc44760259b0e3d836e7d48c5c2e4 --- /dev/null +++ b/core/src/main/java/jenkins/security/LastGrantedAuthoritiesProperty.java @@ -0,0 +1,147 @@ +package jenkins.security; + +import hudson.Extension; +import hudson.model.Descriptor.FormException; +import hudson.model.User; +import hudson.model.UserProperty; +import hudson.security.SecurityRealm; +import jenkins.model.Jenkins; +import net.sf.json.JSONObject; +import org.acegisecurity.Authentication; +import org.acegisecurity.GrantedAuthority; +import org.acegisecurity.GrantedAuthorityImpl; +import org.acegisecurity.userdetails.UserDetails; +import org.kohsuke.stapler.StaplerRequest; + +import javax.annotation.Nonnull; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Remembers the set of {@link GrantedAuthority}s that was obtained the last time the user has logged in. + * + * This allows us to implement {@link User#impersonate()} with proper set of groups. + * + * @author Kohsuke Kawaguchi + * @since 1.556 + */ +public class LastGrantedAuthoritiesProperty extends UserProperty { + private volatile String[] roles; + private long timestamp; + + /** + * Stick to the same object since there's no UI for this. + */ + @Override + public UserProperty reconfigure(StaplerRequest req, JSONObject form) throws FormException { + req.bindJSON(this, form); + return this; + } + + public GrantedAuthority[] getAuthorities() { + String[] roles = this.roles; // capture to a variable for immutability + + GrantedAuthority[] r = new GrantedAuthority[roles==null ? 1 : roles.length+1]; + r[0] = SecurityRealm.AUTHENTICATED_AUTHORITY; + for (int i = 1; i < r.length; i++) { + r[i] = new GrantedAuthorityImpl(roles[i-1]); + } + return r; + } + + /** + * Persist the information with the new {@link UserDetails}. + */ + public void update(Authentication auth) throws IOException { + List roles = new ArrayList(); + for (GrantedAuthority ga : auth.getAuthorities()) { + roles.add(ga.getAuthority()); + } + String[] a = roles.toArray(new String[roles.size()]); + if (!Arrays.equals(this.roles,a)) { + this.roles = a; + this.timestamp = System.currentTimeMillis(); + user.save(); + } + } + + /** + * Removes the recorded information + */ + public void invalidate() throws IOException { + if (roles!=null) { + roles = null; + timestamp = System.currentTimeMillis(); + user.save(); + } + } + + /** + * Listen to the login success/failure event to persist {@link GrantedAuthority}s properly. + */ + @Extension + public static class SecurityListenerImpl extends SecurityListener { + @Override + protected void authenticated(@Nonnull UserDetails details) { + } + + @Override + protected void failedToAuthenticate(@Nonnull String username) { + } + + @Override + protected void loggedIn(@Nonnull String username) { + try { + User u = User.get(username); + LastGrantedAuthoritiesProperty o = u.getProperty(LastGrantedAuthoritiesProperty.class); + if (o==null) + u.addProperty(o=new LastGrantedAuthoritiesProperty()); + Authentication a = Jenkins.getAuthentication(); + if (a!=null && a.getName().equals(username)) + o.update(a); // just for defensive sanity checking + } catch (IOException e) { + LOGGER.log(Level.WARNING, "Failed to record granted authorities",e); + } + } + + @Override + protected void failedToLogIn(@Nonnull String username) { + // while this initially seemed like a good idea to avoid allowing wrong impersonation for too long, + // doing this means a malicious user can break the impersonation capability + // just by failing to login. See ApiTokenFilter that does the following, which seems better: + /* + try { + Jenkins.getInstance().getSecurityRealm().loadUserByUsername(username); + } catch (UserMayOrMayNotExistException x) { + // OK, give them the benefit of the doubt. + } catch (UsernameNotFoundException x) { + // Not/no longer a user; deny the API token. (But do not leak the information that this happened.) + chain.doFilter(request, response); + return; + } catch (DataAccessException x) { + throw new ServletException(x); + } + */ + +// try { +// User u = User.get(username,false,Collections.emptyMap()); +// LastGrantedAuthoritiesProperty o = u.getProperty(LastGrantedAuthoritiesProperty.class); +// if (o!=null) +// o.invalidate(); +// } catch (IOException e) { +// LOGGER.log(Level.WARNING, "Failed to record granted authorities",e); +// } + } + + @Override + protected void loggedOut(@Nonnull String username) { + } + } + + private static final Logger LOGGER = Logger.getLogger(LastGrantedAuthoritiesProperty.class.getName()); +} diff --git a/test/src/test/java/jenkins/security/LastGrantedAuthoritiesPropertyTest.groovy b/test/src/test/java/jenkins/security/LastGrantedAuthoritiesPropertyTest.groovy new file mode 100644 index 0000000000000000000000000000000000000000..fdf5e446cef0116992cb881e2c4e0c69666e06be --- /dev/null +++ b/test/src/test/java/jenkins/security/LastGrantedAuthoritiesPropertyTest.groovy @@ -0,0 +1,74 @@ +package jenkins.security + +import hudson.security.AbstractPasswordBasedSecurityRealm +import hudson.security.GroupDetails +import hudson.security.UserMayOrMayNotExistException +import org.acegisecurity.AuthenticationException +import org.acegisecurity.BadCredentialsException +import org.acegisecurity.GrantedAuthority +import org.acegisecurity.GrantedAuthorityImpl +import org.acegisecurity.userdetails.User +import org.acegisecurity.userdetails.UserDetails +import org.acegisecurity.userdetails.UsernameNotFoundException +import org.junit.Rule +import org.junit.Test +import org.jvnet.hudson.test.JenkinsRule +import org.springframework.dao.DataAccessException + +/** + * + * + * @author Kohsuke Kawaguchi + */ +class LastGrantedAuthoritiesPropertyTest { + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Test + public void basicflow() { + j.jenkins.securityRealm = new TestSecurityRealm() + + // login, and make sure it leaves the LastGrantedAuthoritiesProperty object + def wc = j.createWebClient() + wc.login("alice","alice:development:us") + + def u = hudson.model.User.get("alice") + def p = u.getProperty(LastGrantedAuthoritiesProperty.class) + assertAuthorities(p,"authenticated:alice:development:us") + assertAuthorities(u.impersonate(),"authenticated:alice:development:us") + + // change should be reflected right away + wc.login("alice","alice:development:uk") + p = u.getProperty(LastGrantedAuthoritiesProperty.class) + assertAuthorities(p,"authenticated:alice:development:uk") + assertAuthorities(u.impersonate(),"authenticated:alice:development:uk") + } + + void assertAuthorities(p,expected) { + assert p.authorities*.authority.join(":")==expected + } + + /** + * SecurityRealm that cannot load information about other users, such Active Directory. + */ + private class TestSecurityRealm extends AbstractPasswordBasedSecurityRealm { + @Override + protected UserDetails authenticate(String username, String password) throws AuthenticationException { + if (password=="error") + throw new BadCredentialsException(username); + def authorities = password.split(":").collect { new GrantedAuthorityImpl(it) } + + return new User(username,"",true,authorities.toArray(new GrantedAuthority[0])) + } + + @Override + GroupDetails loadGroupByGroupname(String groupname) throws UsernameNotFoundException, DataAccessException { + throw new UnsupportedOperationException() + } + + @Override + UserDetails loadUserByUsername(String username) throws UsernameNotFoundException, DataAccessException { + throw new UserMayOrMayNotExistException("fallback"); + } + } +}