User Impersonation with Spring Security SwitchUserFilter

A common requirement for secured applications is that admin / super users are able to login as any other user. For example, it may be helpful for a customer support analyst to access a system as if they were a specific real customer. The obvious way to do this is for the admin user to ask for the customer’s password or look it up in the password database. This is usually an unacceptable security compromise – no one should know a customer’s password except for the customer. And if the password database is implemented correctly it should be technically impossible for anyone – not even a system admin or DBA – to  discover a user’s password.

An alternative solution is to allow admin users to login with their own unique username and password and then allow them to impersonate any other user. After the admin user has logged in, they can enter the username of another user (no need for their password) and then view the application as if they had logged in as that user. Implementing user impersonation in this way also has the advantage that the system knows who has really logged in. If the system has an audit log, we can audit actions against the real admin user, rather than the impersonated user.

Implementing a user impersonation feature from scratch would be tricky and possibly introduce vulnerabilities to the system. Fortunately, this feature is available in Spring Security.

The following example is taken from the current release of the Spanners demo application, available on GitHub.

SwitchUserFilter

The starting point for user impersonation in Spring Security is the SwitchUserFilter. This Filter creates a URL that can be used to update the SecurityContext so that a different user is logged in. Here’s an example user switch URL:

The /admin/impersonate URL was configured by adding the following to the application’s spring-security-context.xml:

The switchUserProcessingFilter is set up with the following settings:

  1. A reference to the userDetailsService bean, configured by Spring, is injected
  2. The switchUserUrl is set to /admin/impersonate. Any requests to /admin/impersonate will be handled by the SwitchUserFilter.
  3. The targetUrl is the first page shown on a successful user switch
  4. The switchFailureUrl is shown on failure to switch user. I prefer to have this go back to the switch user form (see below) rather than show a dedicated error page.

The filter is configured in addition to the filters configured automatically by Spring Security (using <http auto-config="true">) at the position ‘ SWITCH_USER_FILTER’.

Switch User form

With the above security configuration, it is possible for ADMIN users to type in a URL that switches them to another user’s profile. Rather than access the switch user URL directly though, it would be easier to submit a form.

Switch User Form

There’s no need for a Spring Controller to handle the POST submission of this form. It can just submit a GET request directly to the switch filter URL:

Securing the form and filter URL

If no additional security rules are configured then any user could access the user switch page or the processing filter. This would allow any user of the application to be able to impersonate any other user. Both the form page and the filter URL should be protected so that only users with ADMIN role can access them:

If any other user attempts to access the form or the processing filter URL, they’ll get an HTTP 403 Forbidden error. It is absolutely vital to secure the configured switchUserUrl in this way to prevent ordinary users from accessing this functionality.

Who am I? No, who am I really?

Once we’ve switched to another user using this mechanism, the Authentication object in the SecurityContext is that of the switched user. If you query the current user’s name, permissions or roles, you’ll get those of the switched user, not those of the ADMIN user who actually logged in. In some cases, we need to check details of the real user. These are stored as an additional GrantedAuthority of the switched user. An instance of SwitchUserGrantedAuthority signifies that the current user is being impersonated. The original ADMIN user can be retrieved via SwitchUserGrantedAuthority.getSource().

The SwitchUserGrantedAuthority always corresponds to a role named ‘ROLE_PREVIOUS_ADMINISTRATOR’, as defined in SwitchUserFilter. This allows us to easily grant access to ADMIN users even if they’re currently impersonating another user – like this:

Testing access rules

It’s worth adding unit tests around expected access rules. In particular, we should verify that non-admin users can never access the user switch page. This can easily be tested using the @WithMockUser annotation with the Spring MVC Test Framework, as detailed in a previous post. As an example, this test verifies that ADMIN users can access the restricted SwitchUserController URL, even if they’re currently impersonating a user with the EDITOR role.

 

12 Comments

  • Oleg Konovalov
    July 6, 2016 - 3:21 pm | Permalink

    Very interesting post! I am new to Spring Security, trying to implement user impersonation by Admin in large Flex+Java/Spring web app. I tried to run your Spanner code (Spring4 version, although I need Spring3 – that version gave me lots and lots of exceptions in tests, so I decided to try Spring4).
    So the site runs OK, but when I try to go to /admin/impersonate?username=jones, getting error on the page: “You do not have permission to perform this operation.” Am I doing something wrong? Please reply via email ASAP. And I still need to make Spring3 version to work ;-). Best Regards, Oleg.

  • Oleg Konovalov
    July 6, 2016 - 4:12 pm | Permalink

    Oh, I did not get the proper scenario:
    – login as admin,
    – click on “switch user”
    – type ‘jones’
    Yes, that 3.1 pom works fine. Great!

    Now will try to get Spring3 (SpringSmokeTest BooksApp from 6/2013 “SU and SUDO”) version to work.
    Here is exception I get at mvn clean install:
    …root of factory hierarchy
    10:55:35.313 | ERROR | | main | o.s.t.c.TestContextManager | Caught exception while allowing TestExecutionListener [or[email protected]c333c60] to prepare test instance [[email protected]]
    java.lang.IllegalArgumentException: null
    at org.springframework.asm.ClassReader.(Unknown Source) ~[spring-core-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.asm.ClassReader.(Unknown Source) ~[spring-core-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.asm.ClassReader.(Unknown Source) ~[spring-core-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.core.type.classreading.SimpleMetadataReader.(SimpleMetadataReader.java:53) ~[spring-core-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    Wrapped by: org.springframework.core.NestedIOException: ASM ClassReader failed to parse class file – probably due to a new Java class file version that isn’t supported yet: class path resource [java/lang/Cloneable.class]; nested exception is java.lang.IllegalArgumentException
    at org.springframework.core.type.classreading.SimpleMetadataReader.(SimpleMetadataReader.java:56) ~[spring-core-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.core.type.classreading.SimpleMetadataReaderFactory.getMetadataReader(SimpleMetadataReaderFactory.java:80) ~[spring-core-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.core.type.classreading.CachingMetadataReaderFactory.getMetadataReader(CachingMetadataReaderFactory.java:102) ~[spring-core-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.core.type.classreading.SimpleMetadataReaderFactory.getMetadataReader(SimpleMetadataReaderFactory.java:76) ~[spring-core-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.core.type.filter.AbstractTypeHierarchyTraversingFilter.match(AbstractTypeHierarchyTraversingFilter.java:105) ~[spring-core-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.core.type.filter.AbstractTypeHierarchyTraversingFilter.match(AbstractTypeHierarchyTraversingFilter.java:95) ~[spring-core-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.core.type.filter.AbstractTypeHierarchyTraversingFilter.match(AbstractTypeHierarchyTraversingFilter.java:105) ~[spring-core-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.core.type.filter.AbstractTypeHierarchyTraversingFilter.match(AbstractTypeHierarchyTraversingFilter.java:95) ~[spring-core-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.core.type.filter.AbstractTypeHierarchyTraversingFilter.match(AbstractTypeHierarchyTraversingFilter.java:105) ~[spring-core-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.core.type.filter.AbstractTypeHierarchyTraversingFilter.match(AbstractTypeHierarchyTraversingFilter.java:76) ~[spring-core-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider.isCandidateComponent(ClassPathScanningCandidateComponentProvider.java:333) ~[spring-context-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider.findCandidateComponents(ClassPathScanningCandidateComponentProvider.java:267) ~[spring-context-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    Wrapped by: org.springframework.beans.factory.BeanDefinitionStoreException: Failed to read candidate component class: file [C:OlegspringImpersonationtargetclassescomblogspotnurkiewiczSlf4jSessionLogger.class]; nested exception is org.springframework.core.NestedIOException: ASM ClassReader failed to parse class file – probably due to a new Java class file version that isn’t supported yet: class path resource [java/lang/Cloneable.class]; nested exception is java.lang.IllegalArgumentException
    at org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider.findCandidateComponents(ClassPathScanningCandidateComponentProvider.java:290) ~[spring-context-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.data.repository.config.RepositoryConfigurationSourceSupport.getCandidates(RepositoryConfigurationSourceSupport.java:53) ~[spring-data-commons-1.5.1.RELEASE.jar:na]
    at org.springframework.data.repository.config.RepositoryConfigurationExtensionSupport.getRepositoryConfigurations(RepositoryConfigurationExtensionSupport.java:53) ~[spring-data-commons-1.5.1.RELEASE.jar:na]
    at org.springframework.data.repository.config.RepositoryBeanDefinitionRegistrarSupport.registerBeanDefinitions(RepositoryBeanDefinitionRegistrarSupport.java:85) ~[spring-data-commons-1.5.1.RELEASE.jar:na]
    at org.springframework.context.annotation.ConfigurationClassParser.processImport(ConfigurationClassParser.java:395) ~[spring-context-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.context.annotation.ConfigurationClassParser.doProcessConfigurationClass(ConfigurationClassParser.java:207) ~[spring-context-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.context.annotation.ConfigurationClassParser.processConfigurationClass(ConfigurationClassParser.java:165) ~[spring-context-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.context.annotation.ConfigurationClassParser.parse(ConfigurationClassParser.java:131) ~[spring-context-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.context.annotation.ConfigurationClassPostProcessor.processConfigBeanDefinitions(ConfigurationClassPostProcessor.java:285) ~[spring-context-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.context.annotation.ConfigurationClassPostProcessor.postProcessBeanDefinitionRegistry(ConfigurationClassPostProcessor.java:223) ~[spring-context-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.context.support.AbstractApplicationContext.invokeBeanFactoryPostProcessors(AbstractApplicationContext.java:630) ~[spring-context-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:461) ~[spring-context-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.test.context.support.AbstractGenericContextLoader.loadContext(AbstractGenericContextLoader.java:120) ~[spring-test-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.test.context.support.AbstractGenericContextLoader.loadContext(AbstractGenericContextLoader.java:60) ~[spring-test-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.test.context.support.AbstractDelegatingSmartContextLoader.delegateLoading(AbstractDelegatingSmartContextLoader.java:100) ~[spring-test-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.test.context.support.AbstractDelegatingSmartContextLoader.loadContext(AbstractDelegatingSmartContextLoader.java:248) ~[spring-test-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.test.context.CacheAwareContextLoaderDelegate.loadContextInternal(CacheAwareContextLoaderDelegate.java:64) ~[spring-test-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.test.context.CacheAwareContextLoaderDelegate.loadContext(CacheAwareContextLoaderDelegate.java:91) ~[spring-test-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    Wrapped by: java.lang.IllegalStateException: Failed to load ApplicationContext
    at org.springframework.test.context.CacheAwareContextLoaderDelegate.loadContext(CacheAwareContextLoaderDelegate.java:99) ~[spring-test-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.test.context.TestContext.getApplicationContext(TestContext.java:122) ~[spring-test-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.test.context.web.ServletTestExecutionListener.setUpRequestContextIfNecessary(ServletTestExecutionListener.java:105) ~[spring-test-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.test.context.web.ServletTestExecutionListener.prepareTestInstance(ServletTestExecutionListener.java:74) ~[spring-test-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.test.context.TestContextManager.prepareTestInstance(TestContextManager.java:312) ~[spring-test-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.createTest(SpringJUnit4ClassRunner.java:211) [spring-test-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.test.context.junit4.SpringJUnit4ClassRunner$1.runReflectiveCall(SpringJUnit4ClassRunner.java:288) [spring-test-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) [junit-4.11.jar:na]
    at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.methodBlock(SpringJUnit4ClassRunner.java:284) [spring-test-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.runChild(SpringJUnit4ClassRunner.java:231) [spring-test-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.runChild(SpringJUnit4ClassRunner.java:88) [spring-test-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.junit.runners.ParentRunner$3.run(ParentRunner.java:238) [junit-4.11.jar:na]
    at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:63) [junit-4.11.jar:na]
    at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:236) [junit-4.11.jar:na]
    at org.junit.runners.ParentRunner.access$000(ParentRunner.java:53) [junit-4.11.jar:na]
    at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:229) [junit-4.11.jar:na]
    at org.springframework.test.context.junit4.statements.RunBeforeTestClassCallbacks.evaluate(RunBeforeTestClassCallbacks.java:61) [spring-test-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.springframework.test.context.junit4.statements.RunAfterTestClassCallbacks.evaluate(RunAfterTestClassCallbacks.java:71) [spring-test-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.junit.runners.ParentRunner.run(ParentRunner.java:309) [junit-4.11.jar:na]
    at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.run(SpringJUnit4ClassRunner.java:174) [spring-test-3.2.3.RELEASE.jar:3.2.3.RELEASE]
    at org.apache.maven.surefire.junit4.JUnit4Provider.execute(JUnit4Provider.java:252) [surefire-junit4-2.12.4.jar:2.12.4]
    at org.apache.maven.surefire.junit4.JUnit4Provider.executeTestSet(JUnit4Provider.java:141) [surefire-junit4-2.12.4.jar:2.12.4]
    at org.apache.maven.surefire.junit4.JUnit4Provider.invoke(JUnit4Provider.java:112) [surefire-junit4-2.12.4.jar:2.12.4]
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_71]
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_71]
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_71]
    at java.lang.reflect.Method.invoke(Method.java:497) ~[na:1.8.0_71]
    at org.apache.maven.surefire.util.ReflectionUtils.invokeMethodWithArray(ReflectionUtils.java:189) [surefire-api-2.12.4.jar:2.12.4]
    at org.apache.maven.surefire.booter.ProviderFactory$ProviderProxy.invoke(ProviderFactory.java:165) [surefire-booter-2.12.4.jar:2.12.4]
    at org.apache.maven.surefire.booter.ProviderFactory.invokeProvider(ProviderFactory.java:85) [surefire-booter-2.12.4.jar:2.12.4]
    at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:115) [surefire-booter-2.12.4.jar:2.12.4]
    at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:75) [surefire-booter-2.12.4.jar:2.12.4]

  • Oleg Konovalov
    July 6, 2016 - 4:32 pm | Permalink

    Ouch, I got that Books App from another blog, I got confused. My apologies!!!
    Is there way to make your Spanners impersonation work with Spring3?
    Maybe some earlier version of it?

    Please advise, Oleg.

    • July 6, 2016 - 9:55 pm | Permalink

      Hi Oleg
      Glad you managed to get the demo working. It does use one or two features that were added in Spring 4 so I wouldn’t expect it to build against Spring 3. One I can think of straight away is the spring-security-test features mentioned in a previous post on Testing with Mock Users in Spring / Spring MVC. However, the SwitchUserFilter behind user impersonation was available in Spring 3 so you should be able to get this working with a little work.

      The Spanners Demo app did build against Spring 3 up until version 2.6 but unfortunately the user impersonation feature was in a later version. If you’re stuck, you could try looking at the project’s revision history in GitHub. The changes for the user impersonation feature were made between 15th June and 20th August 2015. This may help to show exactly what was changed to make this work.

      Hope this helps and good luck!

  • Arvind
    October 15, 2016 - 5:43 am | Permalink

    This concept is very helpfull for Impersonate

  • Oleg Konovalov
    October 31, 2016 - 4:11 pm | Permalink

    Hi Stuart,

    I upgraded to latest Spring4 and now trying to do that impersonation again.

    I am trying to do that in Flex+Java8/Spring web app,
    where we have 2 GUIs – admin and user (as Flex modules) on one URL,
    so it loads a proper SWF file based on user role.
    Also currently each user can have only 1 role.
    I wonder, if that kind of Spring User Impersonation can still work in my case.

    I can try to separate those GUIs by using deep linking (URL fragments, whatever comes after ‘#’ in URL) if you think that might help.
    At the moment I do not have a special URL to impersonate, it is just a Flex button on Admin GUI.
    So when I login to AdminGUI (as user ‘admin1’), select a regular user (say ‘user1’)
    and click on Impersonate button,
    it goes through the route of “already authenticated”, ignores that ‘user1’.
    and logs me in back to Admin GUI as admin1.

    Please advise.

    TIA,
    Oleg.

  • rik
    January 12, 2017 - 6:24 pm | Permalink

    Do you have any idea how to implement a custom SwitchUserAuthorityChanger class? I have a need to override the modifyGrantedAuthorities method, but I can’t find any documentation on how to wire the new class into spring security.

    Thanks,

    rik.

    • January 12, 2017 - 9:19 pm | Permalink

      Hi Rik
      It’s not something I’ve ever done but I see you can inject an instance of SwitchUserAuthorityChanger into the SwitchUserFilter. If you can create your own implementation of SwitchUserAuthorityChanger that returns the GrantedAuthoritys you need, you should be able to wire it up like this (assuming you’re using XML configuration):

      <beans:bean id="switchUserProcessingFilter" class="org.springframework.security.web.authentication.switchuser.SwitchUserFilter">
      <beans:property name="userDetailsService" ref="userDetailsService"/>
      <beans:property name="switchUserUrl" value="/admin/impersonate"/>
      <beans:property name="targetUrl" value="/displaySpanners"/>
      <beans:property name="switchFailureUrl" value="/admin/switchUser"/>
      <beans:property name="switchUserAuthorityChanger" ref="riksSwitchUserAuthorityChanger"/>
      </beans:bean>

      <beans:bean id="mySwitchUserAuthorityChanger" class="com.acme.RiksSwitchUserAuthorityChanger"/>

      Like I say, never tried it myself so be sure to let us know how you get on!

  • Pramod Nagar
    July 26, 2017 - 11:14 am | Permalink

    I want to check whether the user we are requesting to “login as” is sub user of current logged in user or not, for that I need to change the switch user filter ,how I can check that.

    Please help me to resolve this.

    • July 27, 2017 - 8:01 pm | Permalink

      Hi Pramod
      I’m not sure exactly what you mean by sub user here but if you want to do any additional access checks before switching user here’s a couple of things to try:
      1. Override the SwitchUserFilter. In particular, the attemptSwitchUser method may be relevant.
      OR
      2. Provide a custom UserDetailsChecker to your SwitchUserFilter. Implement this such that it throws an exception when the “login as” user is not a sub user of the current logged in user.

      I hope that helps you and best of luck!

  • ahmed itani
    October 17, 2017 - 7:34 am | Permalink

    Hi,

    What if the impersonated user is disabled the admin will not be able to access. How to make an exception that admin can impersonate the account even if the account is expired or disabled.

    • October 28, 2017 - 3:42 pm | Permalink

      Hi Ahmed
      Great point! I can see this being an absolutely essential use case.

      I’d suggest injecting a UserDetailsChecker into the SwitchUserFilter. This decides if a user is allowed access. The default implementation is AccountStatusUserDetailsChecker. You’ll see it checks if a user is locked / disabled / expired and throws an exception. In order to impersonate any user – even if they’re locked / disabled / expired, just create an implementation of UserDetailsChecker that allows anything and never throws an exception.

      Hope that’s of help and let us know how you get on!

  • Leave a Reply

    Your email address will not be published. Required fields are marked *