User-Impersonation mit Spring-Boot und Spring-Security
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!