提交 b7f42b2e 编写于 作者: W Wadeck Follonier 提交者: Oleg Nenashev

[JENKINS-27027] Notify the SecurityListener on authentication (#3074)

* [JENKINS-27026] Notify the SecurityListener in case of Token based authentication success
- due the current version of the method, the UserDetails required for the event was not accessible. In order to stay with the same API in SecurityListener, two "protected" methods were created to split the job and let the UserDetails accessible

* - add test to ensure the SecurityListener is called for REST Token but also for regular basic auth

* - remove the comment about the split, will be put in GitHub comment instead

* - add check for anonymous call instead of just putting a comment
- remove the constructor in the dummy
- add link to PR from Daniel to simplify a call

* - separate the before/after to save one clear and be more explicit
- put more meaning in the assertLastEventIs method by explicitly say we will remove the last event

* - add comment about why we do not fire the "failedToAuthenticated" in the case of an invalid token (tips: it's because it could be a valid password)

* - also add the authenticated trigger on legacy filter as pointed by Ivan

* - add support of event on CLI remoting authentication
- adjust tests by moving the helper class used to spy on events

* - as mentioned Yvan, the code had some problems with null checking, so the approach is changed in order to encapsulate all that internal mechanism

* - add javadoc
- open the getUserDetailsForImpersonation from the User (will let the SSHD module to retrieve UserDetails from that)

* - remove single quote in log messages

* - basic corrections requested by Jesse

* - just another typo

* - adjust the javadoc for SecurityListener events

* - add the link to Jenkins#Anonymous

* - add link (not using see)

* - update comment on the isAnonymous as we (me + Oleg) do not find a best place at the moment

* - put the new method isAnonymous in ACL instead of Functions

* - little typo
- add requirement about the SecurityContext authentication
上级 a82a47a8
......@@ -26,6 +26,7 @@
package hudson;
import hudson.model.Slave;
import hudson.security.*;
import jenkins.util.SystemProperties;
import hudson.cli.CLICommand;
import hudson.console.ConsoleAnnotationDescriptor;
......@@ -56,11 +57,6 @@ import hudson.model.View;
import hudson.scm.SCM;
import hudson.scm.SCMDescriptor;
import hudson.search.SearchableModelObject;
import hudson.security.AccessControlled;
import hudson.security.AuthorizationStrategy;
import hudson.security.GlobalSecurityConfiguration;
import hudson.security.Permission;
import hudson.security.SecurityRealm;
import hudson.security.captcha.CaptchaSupport;
import hudson.security.csrf.CrumbIssuer;
import hudson.slaves.Cloud;
......@@ -137,6 +133,7 @@ import jenkins.model.Jenkins;
import jenkins.model.ModelObjectWithChildren;
import jenkins.model.ModelObjectWithContextMenu;
import org.acegisecurity.Authentication;
import org.acegisecurity.providers.anonymous.AnonymousAuthenticationToken;
import org.apache.commons.jelly.JellyContext;
import org.apache.commons.jelly.JellyTagException;
......@@ -1582,7 +1579,7 @@ public class Functions {
* Checks if the current user is anonymous.
*/
public static boolean isAnonymous() {
return Jenkins.getAuthentication() instanceof AnonymousAuthenticationToken;
return ACL.isAnonymous(Jenkins.getAuthentication());
}
/**
......
......@@ -30,6 +30,8 @@ import hudson.ExtensionPoint;
import hudson.cli.declarative.CLIMethod;
import hudson.ExtensionPoint.LegacyInstancesAreScopedToHudson;
import hudson.Functions;
import hudson.security.ACL;
import jenkins.security.SecurityListener;
import jenkins.util.SystemProperties;
import hudson.cli.declarative.OptionHandlerExtension;
import jenkins.model.Jenkins;
......@@ -44,6 +46,8 @@ import org.acegisecurity.Authentication;
import org.acegisecurity.BadCredentialsException;
import org.acegisecurity.context.SecurityContext;
import org.acegisecurity.context.SecurityContextHolder;
import org.acegisecurity.userdetails.User;
import org.acegisecurity.userdetails.UserDetails;
import org.apache.commons.discovery.ResourceClassIterator;
import org.apache.commons.discovery.ResourceNameIterator;
import org.apache.commons.discovery.resource.ClassLoaders;
......@@ -344,8 +348,16 @@ public abstract class CLICommand implements ExtensionPoint, Cloneable {
@Deprecated
protected Authentication loadStoredAuthentication() throws InterruptedException {
try {
if (channel!=null)
return new ClientAuthenticationCache(channel).get();
if (channel!=null){
Authentication authLoadedFromCache = new ClientAuthenticationCache(channel).get();
if(!ACL.isAnonymous(authLoadedFromCache)){
UserDetails userDetails = new CLIUserDetails(authLoadedFromCache);
SecurityListener.fireAuthenticated(userDetails);
}
return authLoadedFromCache;
}
} catch (IOException e) {
stderr.println("Failed to access the stored credential");
Functions.printStackTrace(e, stderr); // recover
......@@ -642,4 +654,16 @@ public abstract class CLICommand implements ExtensionPoint, Cloneable {
}
}
}
/**
* User details loaded from the CLI {@link ClientAuthenticationCache}
* The user is never anonymous since it must be authenticated to be stored in the cache
*/
@Deprecated
@Restricted(NoExternalUse.class)
private static class CLIUserDetails extends User {
private CLIUserDetails(Authentication auth) {
super(auth.getName(), "", true, true, true, true, auth.getAuthorities());
}
}
}
......@@ -22,6 +22,8 @@ import java.util.logging.Level;
import java.util.logging.Logger;
import jenkins.security.HMACConfidentialKey;
import javax.annotation.Nonnull;
/**
* Represents the authentication credential store of the CLI client.
*
......@@ -73,7 +75,7 @@ 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 Authentication get() {
public @Nonnull Authentication get() {
Jenkins h = Jenkins.getActiveInstance();
String val = props.getProperty(getPropertyKey());
if (val == null) {
......@@ -100,6 +102,7 @@ public class ClientAuthenticationCache implements Serializable {
LOGGER.log(Level.FINER, "Loaded stored CLI authentication for {0}", username);
return new UsernamePasswordAuthenticationToken(u.getUsername(), "", u.getAuthorities());
} catch (AuthenticationException | DataAccessException e) {
//TODO there is no check to be consistent with User.ALLOW_NON_EXISTENT_USER_TO_LOGIN
LOGGER.log(Level.FINE, "Stored CLI authentication did not correspond to a valid user: " + username, e);
return Jenkins.ANONYMOUS;
}
......
......@@ -3,6 +3,7 @@ package hudson.cli;
import hudson.Extension;
import java.io.PrintStream;
import jenkins.model.Jenkins;
import jenkins.security.SecurityListener;
import org.acegisecurity.Authentication;
import org.kohsuke.args4j.CmdLineException;
......@@ -45,6 +46,8 @@ public class LoginCommand extends CLICommand {
ClientAuthenticationCache store = new ClientAuthenticationCache(checkChannel());
store.set(a);
SecurityListener.fireLoggedIn(a.getName());
return 0;
}
......
package hudson.cli;
import hudson.Extension;
import jenkins.security.SecurityListener;
import org.acegisecurity.Authentication;
import java.io.PrintStream;
/**
......@@ -27,7 +30,13 @@ public class LogoutCommand extends CLICommand {
@Override
protected int run() throws Exception {
ClientAuthenticationCache store = new ClientAuthenticationCache(checkChannel());
Authentication auth = store.get();
store.remove();
SecurityListener.fireLoggedOut(auth.getName());
return 0;
}
}
......@@ -326,23 +326,70 @@ public class User extends AbstractModelObject implements AccessControlled, Descr
* @since 1.419
*/
public @Nonnull Authentication impersonate() throws UsernameNotFoundException {
return this.impersonate(this.getUserDetailsForImpersonation());
}
/**
* This method checks with {@link SecurityRealm} if the user is a valid user that can login to the security realm.
* If {@link SecurityRealm} is a kind that does not support querying information about other users, this will
* use {@link LastGrantedAuthoritiesProperty} to pick up the granted authorities as of the last time the user has
* logged in.
*
* @return userDetails for the user, in case he's not found but seems legitimate, we provide a userDetails with minimum access
*
* @throws UsernameNotFoundException
* If this user is not a valid user in the backend {@link SecurityRealm}.
*/
public @Nonnull UserDetails getUserDetailsForImpersonation() throws UsernameNotFoundException {
ImpersonatingUserDetailsService userDetailsService = new ImpersonatingUserDetailsService(
Jenkins.getInstance().getSecurityRealm().getSecurityComponents().userDetails
);
try {
UserDetails u = new ImpersonatingUserDetailsService(
Jenkins.getInstance().getSecurityRealm().getSecurityComponents().userDetails).loadUserByUsername(id);
return new UsernamePasswordAuthenticationToken(u.getUsername(), "", u.getAuthorities());
UserDetails userDetails = userDetailsService.loadUserByUsername(id);
LOGGER.log(Level.FINE, "Impersonation of the user {0} was a success", new Object[]{ id });
return userDetails;
} catch (UserMayOrMayNotExistException e) {
LOGGER.log(Level.FINE, "The user {0} may or may not exist in the SecurityRealm, so we provide minimum access", new Object[]{ id });
// backend can't load information about other users. so use the stored information if available
} catch (UsernameNotFoundException e) {
// if the user no longer exists in the backend, we need to refuse impersonating this user
if (!ALLOW_NON_EXISTENT_USER_TO_LOGIN)
if(ALLOW_NON_EXISTENT_USER_TO_LOGIN){
LOGGER.log(Level.FINE, "The user {0} was not found in the SecurityRealm but we are required to let it pass, due to ALLOW_NON_EXISTENT_USER_TO_LOGIN", new Object[]{ id });
}else{
LOGGER.log(Level.FINE, "The user {0} was not found in the SecurityRealm", new Object[]{ id });
throw e;
}
} catch (DataAccessException e) {
// seems like it's in the same boat as UserMayOrMayNotExistException
LOGGER.log(Level.FINE, "The user {0} retrieval just threw a DataAccess exception with msg = {1}, so we provide minimum access", new Object[]{ id, e.getMessage() });
}
return new LegitimateButUnknownUserDetails(id);
}
/**
* Only used for a legitimate user we have no idea about. We give it only minimum access
*/
private static class LegitimateButUnknownUserDetails extends org.acegisecurity.userdetails.User{
private LegitimateButUnknownUserDetails(String username) throws IllegalArgumentException {
super(
username, "",
true, true, true, true,
new GrantedAuthority[]{SecurityRealm.AUTHENTICATED_AUTHORITY}
);
}
}
// seems like a legitimate user we have no idea about. proceed with minimum access
return new UsernamePasswordAuthenticationToken(id, "",
new GrantedAuthority[]{SecurityRealm.AUTHENTICATED_AUTHORITY});
/**
* Creates an {@link Authentication} object that represents this user using the given userDetails
*
* @param userDetails Provided by {@link #getUserDetailsForImpersonation()}.
* @see #getUserDetailsForImpersonation()
*/
@Restricted(NoExternalUse.class)
public @Nonnull Authentication impersonate(@Nonnull UserDetails userDetails) {
return new UsernamePasswordAuthenticationToken(userDetails.getUsername(), "", userDetails.getAuthorities());
}
/**
......
......@@ -44,6 +44,7 @@ import org.acegisecurity.context.SecurityContextHolder;
import org.acegisecurity.providers.UsernamePasswordAuthenticationToken;
import org.acegisecurity.acls.sid.PrincipalSid;
import org.acegisecurity.acls.sid.Sid;
import org.acegisecurity.providers.anonymous.AnonymousAuthenticationToken;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
......@@ -319,4 +320,13 @@ public abstract class ACL {
return as(user == null ? Jenkins.ANONYMOUS : user.impersonate());
}
/**
* Checks if the given authentication is anonymous by checking its class.
* @see Jenkins#ANONYMOUS
* @see AnonymousAuthenticationToken
*/
public static boolean isAnonymous(@Nonnull Authentication authentication) {
//TODO use AuthenticationTrustResolver instead to be consistent through the application
return authentication instanceof AnonymousAuthenticationToken;
}
}
......@@ -27,7 +27,10 @@ import hudson.model.User;
import jenkins.model.Jenkins;
import hudson.util.Scrambler;
import jenkins.security.ApiTokenProperty;
import jenkins.security.SecurityListener;
import org.acegisecurity.Authentication;
import org.acegisecurity.context.SecurityContextHolder;
import org.acegisecurity.userdetails.UserDetails;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
......@@ -138,7 +141,12 @@ public class BasicAuthenticationFilter implements Filter {
User u = User.getById(username, true);
ApiTokenProperty t = u.getProperty(ApiTokenProperty.class);
if (t!=null && t.matchesPassword(password)) {
SecurityContextHolder.getContext().setAuthentication(u.impersonate());
UserDetails userDetails = u.getUserDetailsForImpersonation();
Authentication auth = u.impersonate(userDetails);
SecurityListener.fireAuthenticated(userDetails);
SecurityContextHolder.getContext().setAuthentication(auth);
try {
chain.doFilter(request,response);
} finally {
......
......@@ -3,6 +3,7 @@ package jenkins.security;
import hudson.Extension;
import hudson.model.User;
import org.acegisecurity.Authentication;
import org.acegisecurity.userdetails.UserDetails;
import org.acegisecurity.userdetails.UsernameNotFoundException;
import org.springframework.dao.DataAccessException;
......@@ -21,6 +22,10 @@ import static java.util.logging.Level.*;
*/
@Extension
public class BasicHeaderApiTokenAuthenticator extends BasicHeaderAuthenticator {
/**
* Note: if the token does not exist or does not match, we do not use {@link SecurityListener#fireFailedToAuthenticate(String)}
* because it will be done in the {@link BasicHeaderRealPasswordAuthenticator} in the case the password is not valid either
*/
@Override
public Authentication authenticate(HttpServletRequest req, HttpServletResponse rsp, String username, String password) throws ServletException {
// attempt to authenticate as API token
......@@ -28,7 +33,12 @@ public class BasicHeaderApiTokenAuthenticator extends BasicHeaderAuthenticator {
ApiTokenProperty t = u.getProperty(ApiTokenProperty.class);
if (t!=null && t.matchesPassword(password)) {
try {
return u.impersonate();
UserDetails userDetails = u.getUserDetailsForImpersonation();
Authentication auth = u.impersonate(userDetails);
SecurityListener.fireAuthenticated(userDetails);
return auth;
} catch (UsernameNotFoundException x) {
// The token was valid, but the impersonation failed. This token is clearly not his real password,
// so there's no point in continuing the request processing. Report this error and abort.
......
......@@ -45,29 +45,32 @@ public abstract class SecurityListener implements ExtensionPoint {
private static final Logger LOGGER = Logger.getLogger(SecurityListener.class.getName());
/**
* Fired when a user was successfully authenticated by password.
* This might be via the web UI, or via REST (not with an API token) or CLI (not with an SSH key).
* Only {@link AbstractPasswordBasedSecurityRealm}s are considered.
* @param details details of the newly authenticated user, such as name and groups
* Fired when a user was successfully authenticated using credentials. It could be password or any other credentials.
* This might be via the web UI, or via REST (using API token or Basic), or CLI (remoting, auth, ssh)
* or any other way plugins can propose.
* @param details details of the newly authenticated user, such as name and groups.
*/
protected void authenticated(@Nonnull UserDetails details){}
/**
* Fired when a user tried to authenticate by password but failed.
* Fired when a user tried to authenticate but failed.
* In case the authentication method uses multiple layers to validate the credentials,
* we do fire this event only when even the last layer failed to authenticate.
* @param username the user
* @see #authenticated
*/
protected void failedToAuthenticate(@Nonnull String username){}
/**
* Fired when a user has logged in via the web UI.
* Fired when a user has logged in. Compared to authenticated, there is a notion of storage / cache.
* Would be called after {@link #authenticated}.
* It should be called after the {@link org.acegisecurity.context.SecurityContextHolder#getContext()}'s authentication is set.
* @param username the user
*/
protected void loggedIn(@Nonnull String username){}
/**
* Fired when a user has failed to log in via the web UI.
* Fired when a user has failed to log in.
* Would be called after {@link #failedToAuthenticate}.
* @param username the user
*/
......
package hudson.security;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import hudson.ExtensionList;
import hudson.cli.CLI;
import hudson.cli.CLICommand;
import jenkins.model.Jenkins;
import jenkins.security.SecurityListener;
import jenkins.security.SpySecurityListener;
import org.acegisecurity.Authentication;
import org.acegisecurity.AuthenticationException;
import org.acegisecurity.BadCredentialsException;
import org.acegisecurity.GrantedAuthority;
import org.acegisecurity.userdetails.User;
import org.acegisecurity.userdetails.UserDetails;
import org.acegisecurity.userdetails.UsernameNotFoundException;
import org.apache.commons.io.input.NullInputStream;
import org.junit.After;
import org.junit.Before;
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.TestExtension;
import org.springframework.dao.DataAccessException;
import java.io.ByteArrayOutputStream;
import java.util.Arrays;
import static org.junit.Assert.*;
/**
* @author Kohsuke Kawaguchi
*/
......@@ -27,6 +37,14 @@ public class CliAuthenticationTest {
@Rule
public JenkinsRule j = new JenkinsRule();
private SpySecurityListener spySecurityListener;
@Before
public void prepareListeners(){
//TODO simplify using #3021 into ExtensionList.lookupSingleton(SpySecurityListener.class)
this.spySecurityListener = ExtensionList.lookup(SecurityListener.class).get(SpySecurityListenerImpl.class);
}
@Test
public void test() throws Exception {
......@@ -40,6 +58,10 @@ public class CliAuthenticationTest {
assertEquals(0, command(args));
}
private void unsuccessfulCommand(String... args) throws Exception {
assertNotEquals(0, command(args));
}
private int command(String... args) throws Exception {
try (CLI cli = new CLI(j.getURL())) {
return cli.execute(args);
......@@ -91,9 +113,86 @@ public class CliAuthenticationTest {
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
successfulCommand("login","--username","abc","--password","abc");
spySecurityListener.authenticatedCalls.assertLastEventIsAndThenRemoveIt(userDetails -> userDetails.getUsername().equals("abc"));
spySecurityListener.loggedInCalls.assertLastEventIsAndThenRemoveIt("abc");
successfulCommand("test"); // now we can run without an explicit credential
spySecurityListener.authenticatedCalls.assertLastEventIsAndThenRemoveIt(userDetails -> userDetails.getUsername().equals("abc"));
spySecurityListener.loggedInCalls.assertNoNewEvents();
successfulCommand("logout");
spySecurityListener.authenticatedCalls.assertLastEventIsAndThenRemoveIt(userDetails -> userDetails.getUsername().equals("abc"));
spySecurityListener.loggedOutCalls.assertLastEventIsAndThenRemoveIt("abc");
successfulCommand("anonymous"); // now we should run as anonymous
spySecurityListener.authenticatedCalls.assertNoNewEvents();
spySecurityListener.failedToAuthenticateCalls.assertNoNewEvents();
spySecurityListener.loggedInCalls.assertNoNewEvents();
spySecurityListener.loggedOutCalls.assertNoNewEvents();
}
@Test
@Issue("JENKINS-27026")
public void loginAsALegitimateUserButUnknown() throws Exception {
j.jenkins.setSecurityRealm(new MockSecurityRealm());
String username = "alice";
unsuccessfulCommand("login","--username",username,"--password","badCredentials");
spySecurityListener.failedToAuthenticateCalls.assertLastEventIsAndThenRemoveIt(username);
spySecurityListener.failedToLogInCalls.assertNoNewEvents();
spySecurityListener.authenticatedCalls.assertNoNewEvents();
spySecurityListener.loggedInCalls.assertNoNewEvents();
// spySecurityListener.failedToLogInCalls.assertLastEventIsAndThenRemoveIt(username);
unsuccessfulCommand("login","--username",username,"--password","usernameNotFound");
spySecurityListener.failedToAuthenticateCalls.assertLastEventIsAndThenRemoveIt(username);
spySecurityListener.failedToLogInCalls.assertNoNewEvents();
spySecurityListener.authenticatedCalls.assertNoNewEvents();
spySecurityListener.loggedInCalls.assertNoNewEvents();
// spySecurityListener.failedToLogInCalls.assertLastEventIsAndThenRemoveIt(username);
unsuccessfulCommand("login","--username",username,"--password","mayOrMayNotExist");
spySecurityListener.failedToAuthenticateCalls.assertLastEventIsAndThenRemoveIt(username);
spySecurityListener.failedToLogInCalls.assertNoNewEvents();
spySecurityListener.authenticatedCalls.assertNoNewEvents();
spySecurityListener.loggedInCalls.assertNoNewEvents();
// spySecurityListener.failedToLogInCalls.assertLastEventIsAndThenRemoveIt(username);
// in case of authentication that throw a DataAccessException (see impersonatingUserDetailsService)
// the CLI login command does not work as expected
unsuccessfulCommand("login","--username",username,"--password","dataAccess");
spySecurityListener.failedToAuthenticateCalls.assertNoNewEvents();
spySecurityListener.failedToLogInCalls.assertNoNewEvents();
spySecurityListener.authenticatedCalls.assertNoNewEvents();
spySecurityListener.loggedInCalls.assertNoNewEvents();
// spySecurityListener.failedToAuthenticateCalls.assertLastEventIsAndThenRemoveIt(username);
// spySecurityListener.failedToLogInCalls.assertLastEventIsAndThenRemoveIt(username);
}
private static class MockSecurityRealm extends AbstractPasswordBasedSecurityRealm {
@Override protected UserDetails authenticate(String username, String password) throws AuthenticationException {
switch(password){
case "badCredentials": throw new BadCredentialsException("BadCredentials requested");
case "usernameNotFound": throw new UsernameNotFoundException("UsernameNotFound requested");
case "mayOrMayNotExist": throw new UserMayOrMayNotExistException("MayOrMayNotExist requested");
case "dataAccess": throw new DataAccessException("DataAccess requested"){};
default:
return new User(
username, password,
true, true, true,
new GrantedAuthority[]{SecurityRealm.AUTHENTICATED_AUTHORITY}
);
}
}
@Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return null;
}
@Override public GroupDetails loadGroupByGroupname(String groupname) throws UsernameNotFoundException, DataAccessException {
return null;
}
}
/**
......@@ -112,4 +211,7 @@ public class CliAuthenticationTest {
assertTrue(out1.contains("Bad Credentials. Search the server log for"));
assertTrue(out2.contains("Bad Credentials. Search the server log for"));
}
@TestExtension
public static class SpySecurityListenerImpl extends SpySecurityListener {}
}
package jenkins.security;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException;
import com.gargoylesoftware.htmlunit.Page;
import com.gargoylesoftware.htmlunit.WebRequest;
import hudson.ExtensionList;
import hudson.model.UnprotectedRootAction;
import hudson.model.User;
import hudson.util.HttpResponses;
import hudson.util.Scrambler;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.jvnet.hudson.test.JenkinsRule;
......@@ -21,6 +21,8 @@ import org.xml.sax.SAXException;
import java.io.IOException;
import java.net.URL;
import static org.junit.Assert.*;
/**
* @author Kohsuke Kawaguchi
*/
......@@ -30,6 +32,14 @@ public class BasicHeaderProcessorTest {
private WebClient wc;
private SpySecurityListener spySecurityListener;
@Before
public void prepareListeners(){
//TODO simplify using #3021 into ExtensionList.lookupSingleton(SpySecurityListener.class)
this.spySecurityListener = ExtensionList.lookup(SecurityListener.class).get(SpySecurityListenerImpl.class);
}
/**
* Tests various ways to send the Basic auth.
*/
......@@ -43,39 +53,54 @@ public class BasicHeaderProcessorTest {
// call without authentication
makeRequestWithAuthAndVerify(null, "anonymous");
spySecurityListener.authenticatedCalls.assertNoNewEvents();
spySecurityListener.failedToAuthenticateCalls.assertNoNewEvents();
// call with API token
ApiTokenProperty t = foo.getProperty(ApiTokenProperty.class);
final String token = t.getApiToken();
makeRequestWithAuthAndVerify("foo:"+token, "foo");
//TODO verify why there are two events "authenticated" that are triggered
// the whole authentication process seems to be done twice
spySecurityListener.authenticatedCalls.assertLastEventIsAndThenRemoveIt(u -> u.getUsername().equals("foo"));
// call with invalid API token
makeRequestAndFail("foo:abcd"+token);
spySecurityListener.failedToAuthenticateCalls.assertLastEventIsAndThenRemoveIt("foo");
// call with password
makeRequestWithAuthAndVerify("foo:foo", "foo");
spySecurityListener.authenticatedCalls.assertLastEventIsAndThenRemoveIt(u -> u.getUsername().equals("foo"));
// call with incorrect password
makeRequestAndFail("foo:bar");
spySecurityListener.failedToAuthenticateCalls.assertLastEventIsAndThenRemoveIt("foo");
wc.login("bar");
spySecurityListener.authenticatedCalls.assertLastEventIsAndThenRemoveIt(u -> u.getUsername().equals("bar"));
spySecurityListener.loggedInCalls.assertLastEventIsAndThenRemoveIt("bar");
// if the session cookie is valid, then basic header won't be needed
makeRequestWithAuthAndVerify(null, "bar");
spySecurityListener.authenticatedCalls.assertNoNewEvents();
spySecurityListener.failedToAuthenticateCalls.assertNoNewEvents();
// if the session cookie is valid, and basic header is set anyway login should not fail either
makeRequestWithAuthAndVerify("bar:bar", "bar");
spySecurityListener.authenticatedCalls.assertNoNewEvents();
spySecurityListener.failedToAuthenticateCalls.assertNoNewEvents();
// but if the password is incorrect, it should fail, instead of silently logging in as the user indicated by session
makeRequestAndFail("foo:bar");
spySecurityListener.failedToAuthenticateCalls.assertLastEventIsAndThenRemoveIt("foo");
}
private void makeRequestAndFail(String userAndPass) throws IOException, SAXException {
makeRequestWithAuthCodeAndFail(encrypt("Basic", userAndPass));
makeRequestWithAuthCodeAndFail(encode("Basic", userAndPass));
}
private String encrypt(String prefix, String userAndPass) {
private String encode(String prefix, String userAndPass) {
if (userAndPass==null) {
return null;
}
......@@ -92,7 +117,7 @@ public class BasicHeaderProcessorTest {
}
private void makeRequestWithAuthAndVerify(String userAndPass, String username) throws IOException, SAXException {
makeRequestWithAuthCodeAndVerify(encrypt("Basic", userAndPass), username);
makeRequestWithAuthCodeAndVerify(encode("Basic", userAndPass), username);
}
private void makeRequestWithAuthCodeAndVerify(String authCode, String expected) throws IOException, SAXException {
......@@ -116,20 +141,24 @@ public class BasicHeaderProcessorTest {
// call with API token
ApiTokenProperty t = foo.getProperty(ApiTokenProperty.class);
final String token = t.getApiToken();
String authCode1 = encrypt(prefix,"foo:"+token);
String authCode1 = encode(prefix,"foo:"+token);
makeRequestWithAuthCodeAndVerify(authCode1, "foo");
spySecurityListener.authenticatedCalls.assertLastEventIsAndThenRemoveIt(u -> u.getUsername().equals("foo"));
// call with invalid API token
String authCode2 = encrypt(prefix,"foo:abcd"+token);
String authCode2 = encode(prefix,"foo:abcd"+token);
makeRequestWithAuthCodeAndFail(authCode2);
spySecurityListener.failedToAuthenticateCalls.assertLastEventIsAndThenRemoveIt("foo");
// call with password
String authCode3 = encrypt(prefix,"foo:foo");
String authCode3 = encode(prefix,"foo:foo");
makeRequestWithAuthCodeAndVerify(authCode3, "foo");
spySecurityListener.authenticatedCalls.assertLastEventIsAndThenRemoveIt(u -> u.getUsername().equals("foo"));
// call with incorrect password
String authCode4 = encrypt(prefix,"foo:bar");
String authCode4 = encode(prefix,"foo:bar");
makeRequestWithAuthCodeAndFail(authCode4);
spySecurityListener.failedToAuthenticateCalls.assertLastEventIsAndThenRemoveIt("foo");
}
}
......@@ -155,4 +184,7 @@ public class BasicHeaderProcessorTest {
return HttpResponses.plainText(u!=null ? u.getId() : "anonymous");
}
}
@TestExtension
public static class SpySecurityListenerImpl extends SpySecurityListener {}
}
/*
* The MIT License
*
* Copyright (c) 2017, 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;
import org.acegisecurity.userdetails.UserDetails;
import javax.annotation.Nonnull;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
//TODO temporary solution, should be moved to Jenkins Test Harness project
/**
* Mean to be included in test classes to provide a way to spy on the SecurityListener events
* @see jenkins.security.BasicHeaderProcessorTest.SpySecurityListenerImpl
* @see hudson.security.CliAuthenticationTest.SpySecurityListenerImpl
*/
public abstract class SpySecurityListener extends SecurityListener {
public final EventQueue<UserDetails> authenticatedCalls = new EventQueue<>();
public final EventQueue<String> failedToAuthenticateCalls = new EventQueue<>();
public final EventQueue<String> loggedInCalls = new EventQueue<>();
public final EventQueue<String> failedToLogInCalls = new EventQueue<>();
public final EventQueue<String> loggedOutCalls = new EventQueue<>();
public void clearPreviousCalls(){
this.authenticatedCalls.clear();
this.failedToAuthenticateCalls.clear();
this.loggedInCalls.clear();
this.failedToLogInCalls.clear();
this.loggedOutCalls.clear();
}
@Override
protected void authenticated(@Nonnull UserDetails details) {
this.authenticatedCalls.add(details);
}
@Override
protected void failedToAuthenticate(@Nonnull String username) {
this.failedToAuthenticateCalls.add(username);
}
@Override
protected void loggedIn(@Nonnull String username) {
this.loggedInCalls.add(username);
}
@Override
protected void failedToLogIn(@Nonnull String username) {
this.failedToLogInCalls.add(username);
}
@Override
protected void loggedOut(@Nonnull String username) {
this.loggedOutCalls.add(username);
}
public static class EventQueue<T> {
private final List<T> eventList = new ArrayList<>();
private EventQueue add(T t){
eventList.add(t);
return this;
}
public void assertLastEventIsAndThenRemoveIt(T expected){
assertLastEventIsAndThenRemoveIt(expected::equals);
}
public void assertLastEventIsAndThenRemoveIt(Predicate<T> predicate){
if(eventList.isEmpty()){
fail("event list is empty");
}
T t = eventList.remove(eventList.size() - 1);
assertTrue(predicate.test(t));
eventList.clear();
}
public void assertNoNewEvents(){
assertEquals("list of event should be empty", eventList.size(), 0);
}
public void clear(){
eventList.clear();
}
}
}
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册