a dev's blog

Some thoughts about thoughts.

User-Impersonation mit Spring-Boot und Spring-Security

2016-02-21 Development Java Spring

Wenn man sich in andere rein versetzt…

… dann kann das Einem unter Umständen das Leben erleichtern. Beispielweise wenn sich User X bei der eigenen Hotline meldet um einen Fehler in der Anwendung zu reporten. Vielleicht ist es aber auch gar kein Fehler, sondern ein User braucht Unterstützung bei irgendeinem Prozess, der von der Applikation abgedeckt wird. In beiden Fällen ist es meiner Erfahrung nach enorm hilfreich, wenn man selbst die Rolle des Users annehmen kann - und dies ohne zuvor sein Passwort zurücksetzen zu müssen, damit man sich selbst einen Login verschafft. ;)

Wir schauen uns in diesem Post an, wie wir mit wenigen Handgriffen und Spring-Boot sowie Spring-Security genau dieses Feature in unsere Applikation einbauen. Ich habe wie immer eine Beispiel-Implementierung in meinem Git-Repo bereitgestellt. In diesem Repo findet ihr eine etwas ausführliche Demo, mit zwei Thymeleaf Seiten für Login und Darstellung des aktuellen Usernamens.

Setup

Zunächst einmal haben wir ein paar Dependencies, die wir in unser Projekt holen müssen:

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  ...
</dependencies>

Neben dem Spring-Boot-Web-Starter haben wir, wie oben schon angedeutet Spring-Security mit im Boot.

Die Applikation

@SpringBootApplication
public class SpringBootUserImpersonationApplication {

	public static void main(String[] args) {
		SpringApplication.run(SpringBootUserImpersonationApplication.class, args);
	}
}

Wie kommen wir an unsere User?

Zunächst einmal benötigen wir einen UserDetailsService. An sich würde zwar eine In-Memory-Authentication für unsere Mini-Anwendung reichen, jedoch ist der SwitchUserFilter damit nicht glücklich:

@Service
public class DummyUserDetailsService implements UserDetailsService {
  @Override
  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

    if ("admin".equals(username)) {
      return new User(username, "pass", AuthorityUtils.createAuthorityList("ROLE_USER", "ROLE_ADMIN"));
    } else if ("user".equals(username)) {
      return new User(username, "pass", AuthorityUtils.createAuthorityList("ROLE_USER"));
    }

    throw new UsernameNotFoundException("Username not found: " + username);
  }
}

Dieser UserDetailsService registriert zwei User. Einen User admin mit dem Passwort pass sowie einen User user, der ebenfalls das Passwort pass besitzt. Der Admin hat neben der normalen User-Rolle eine weitere Rolle, nämlich die des privilegierten Administrators.

Einbau des SwitchUserFilters

Was jetzt folgt ist die komplette Security-Konfiguration, die wir gleich Schritt für Schritt durchgehen werden:

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

  @Autowired
  public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(userDetailsService());
  }

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
        .antMatchers("/impersonate/logout").hasAnyRole("USER", "ADMIN")
        .antMatchers("/impersonate/**").hasRole("ADMIN")
        .antMatchers("/**").authenticated()
        .and().httpBasic()
        .and().addFilterAfter(switchUserFilter(), FilterSecurityInterceptor.class);
  }

  @Override
  protected UserDetailsService userDetailsService() {
    return new DummyUserDetailsService();
  }

  @Bean
  public SwitchUserFilter switchUserFilter() {
    SwitchUserFilter switchUserFilter = new SwitchUserFilter();
    switchUserFilter.setUserDetailsService(userDetailsService());
    switchUserFilter.setSwitchUserUrl("/impersonate/login");
    switchUserFilter.setExitUserUrl("/impersonate/logout");
    return switchUserFilter;
  }
}

In den Zeilen 5-8 konfigurieren wir zunächst einmal unseren AuthenticationManagerBuilder mittels des von uns erstellten UserDetailsService.

Diesen Service erstellen wir in der Methode userDetailsService() die wir in den Zeilen 20-23 dafür überschreiben.

Die gleiche Methode userDetailsService() wird auch in unserer Methode switchUserFilter() genutzt, die uns eine Singleton-Bean eines SwitchUserFilters erzeugt. Dieser Filter ist das Kernstück der gesamten Implementierung. Neben einer Information, wie der Service an unsere Benutzer kommt, setzen wir auch noch die URLs, die für einen Login (also quasi ein sudo) in einen anderen Benutzer sowie ein Logout herangezogen werden sollen.

Da wir natürlich kein Interesse daran haben, dass jeder User die gleiche Chance hat sich als anderer User auszugeben, verrechten wir die Endpunkte noch, so dass sich zwar jeder aus der Imitation eines anderen Users ausloggen kann, aber nur Admins in der Lage sind sind über den Impersonate-Endpoint anzumelden.

Schlussendlich registrieren wir den neuen Filter noch in unserer FilterChain und zwar nach dem FilterSecurityInterceptor. Die Position der Registrierung an der Stelle ist wichtig. Wird der SwitchUserFilter vor dem FilterSecurityInterceptor durchlaufen, findet sich im Spring-Security-Context noch kein Pricipal, was Aufschluss über den User gibt.

Das Resultat

Wenn wir uns jetzt als admin bei uns in der Anwendung anmelden, so dass wir die Berechtigung für das Wechseln in einen anderen User besitzen und daraufhin die Anwendung unter der URL /impersonate/login?username=user aufrufen, sind wir ab diesem Zeitpunkt mit allen Rollen und Attributen des Users user ausgestattet. Zusätzlich bekommt man allerdings noch die Rolle ROLE_PREVIOUS_ADMINISTRATOR, so dass man ggf. weitreichendere Funktionen aufrufen oder nutzen kann als ein normaler User. Rufen wir daraufhin /impersonate/logout auf, haben wir unseren Ursprünglichen User-Context wieder vollständig hergestellt.

Ich hoffe das Beispiel war ansehnlich und verständlich, wenn nicht - haut mich einfach über die Kommentarfunktion an. ;)

Cheers!