Frühlingssicherheit gegen Apache Shiro

1. Übersicht

Sicherheit ist ein Hauptanliegen in der Welt der Anwendungsentwicklung, insbesondere im Bereich Unternehmensweb- und Mobilanwendungen.

In diesem kurzen Tutorial werden zwei beliebte Java Security-Frameworks verglichen - Apache Shiro und Spring Security .

2. Ein kleiner Hintergrund

Apache Shiro wurde 2004 als JSecurity geboren und 2008 von der Apache Foundation akzeptiert. Bis heute wurden viele Veröffentlichungen veröffentlicht, die neueste Version ist 1.5.3.

Spring Security wurde 2003 als Acegi gestartet und 2008 mit seiner ersten Veröffentlichung in das Spring Framework aufgenommen. Seit seiner Einführung wurden mehrere Iterationen durchgeführt, und die aktuelle GA-Version zum Zeitpunkt des Schreibens ist 5.3.2.

Beide Technologien bieten Authentifizierungs- und Autorisierungsunterstützung sowie Lösungen für Kryptografie und Sitzungsverwaltung . Darüber hinaus bietet Spring Security erstklassigen Schutz vor Angriffen wie CSRF und Sitzungsfixierung.

In den nächsten Abschnitten sehen wir Beispiele, wie die beiden Technologien mit Authentifizierung und Autorisierung umgehen. Zur Vereinfachung verwenden wir grundlegende Spring Boot-basierte MVC-Anwendungen mit FreeMarker-Vorlagen.

3. Apache Shiro konfigurieren

Lassen Sie uns zunächst sehen, wie sich die Konfigurationen zwischen den beiden Frameworks unterscheiden.

3.1. Maven-Abhängigkeiten

Da wir Shiro in einer Spring Boot App verwenden, benötigen wir den Starter und das Shiro-Core- Modul:

 org.apache.shiro shiro-spring-boot-web-starter 1.5.3   org.apache.shiro shiro-core 1.5.3 

Die neuesten Versionen finden Sie auf Maven Central.

3.2. Ein Reich erschaffen

Um Benutzer mit ihren Rollen und Berechtigungen im Speicher zu deklarieren, müssen wir einen Bereich erstellen, der Shiros JdbcRealm erweitert . Wir definieren zwei Benutzer - Tom und Jerry mit den Rollen USER und ADMIN:

public class CustomRealm extends JdbcRealm { private Map credentials = new HashMap(); private Map roles = new HashMap(); private Map permissions = new HashMap(); { credentials.put("Tom", "password"); credentials.put("Jerry", "password"); roles.put("Jerry", new HashSet(Arrays.asList("ADMIN"))); roles.put("Tom", new HashSet(Arrays.asList("USER"))); permissions.put("ADMIN", new HashSet(Arrays.asList("READ", "WRITE"))); permissions.put("USER", new HashSet(Arrays.asList("READ"))); } }

Um das Abrufen dieser Authentifizierung und Autorisierung zu ermöglichen, müssen einige Methoden überschrieben werden:

@Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { UsernamePasswordToken userToken = (UsernamePasswordToken) token; if (userToken.getUsername() == null || userToken.getUsername().isEmpty() || !credentials.containsKey(userToken.getUsername())) { throw new UnknownAccountException("User doesn't exist"); } return new SimpleAuthenticationInfo(userToken.getUsername(), credentials.get(userToken.getUsername()), getName()); } @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { Set roles = new HashSet(); Set permissions = new HashSet(); for (Object user : principals) { try { roles.addAll(getRoleNamesForUser(null, (String) user)); permissions.addAll(getPermissions(null, null, roles)); } catch (SQLException e) { logger.error(e.getMessage()); } } SimpleAuthorizationInfo authInfo = new SimpleAuthorizationInfo(roles); authInfo.setStringPermissions(permissions); return authInfo; } 

Die Methode doGetAuthorizationInfo verwendet einige Hilfsmethoden , um die Rollen und Berechtigungen des Benutzers abzurufen :

@Override protected Set getRoleNamesForUser(Connection conn, String username) throws SQLException { if (!roles.containsKey(username)) { throw new SQLException("User doesn't exist"); } return roles.get(username); } @Override protected Set getPermissions(Connection conn, String username, Collection roles) throws SQLException { Set userPermissions = new HashSet(); for (String role : roles) { if (!permissions.containsKey(role)) { throw new SQLException("Role doesn't exist"); } userPermissions.addAll(permissions.get(role)); } return userPermissions; } 

Als Nächstes müssen wir diesen CustomRealm als Bean in unsere Boot-Anwendung aufnehmen:

@Bean public Realm customRealm() { return new CustomRealm(); }

Um die Authentifizierung für unsere Endpunkte zu konfigurieren, benötigen wir außerdem eine weitere Bean:

@Bean public ShiroFilterChainDefinition shiroFilterChainDefinition() { DefaultShiroFilterChainDefinition filter = new DefaultShiroFilterChainDefinition(); filter.addPathDefinition("/home", "authc"); filter.addPathDefinition("/**", "anon"); return filter; }

Hier haben wir mithilfe einer DefaultShiroFilterChainDefinition- Instanz angegeben, dass auf unseren / home- Endpunkt nur authentifizierte Benutzer zugreifen können.

Das ist alles was wir für die Konfiguration brauchen, Shiro erledigt den Rest für uns.

4. Konfigurieren der Federsicherheit

Nun wollen wir sehen, wie wir im Frühjahr dasselbe erreichen können.

4.1. Maven-Abhängigkeiten

Erstens die Abhängigkeiten:

 org.springframework.boot spring-boot-starter-web   org.springframework.boot spring-boot-starter-security 

Die neuesten Versionen finden Sie auf Maven Central.

4.2. Konfigurationsklasse

Als Nächstes definieren wir unsere Spring Security-Konfiguration in einer Klasse SecurityConfig und erweitern WebSecurityConfigurerAdapter :

@EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests(authorize -> authorize .antMatchers("/index", "/login").permitAll() .antMatchers("/home", "/logout").authenticated() .antMatchers("/admin/**").hasRole("ADMIN")) .formLogin(formLogin -> formLogin .loginPage("/login") .failureUrl("/login-error")); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication() .withUser("Jerry") .password(passwordEncoder().encode("password")) .authorities("READ", "WRITE") .roles("ADMIN") .and() .withUser("Tom") .password(passwordEncoder().encode("password")) .authorities("READ") .roles("USER"); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } } 

As we can see, we built an AuthenticationManagerBuilder object to declare our users with their roles and authorities. Additionally, we encoded the passwords using a BCryptPasswordEncoder.

Spring Security also provides us with its HttpSecurity object for further configurations. For our example, we've allowed:

  • everyone to access our index and login pages
  • only authenticated users to enter the home page and logout
  • only users with ADMIN role to access the admin pages

We've also defined support for form-based authentication to send users to the login endpoint. In case login fails, our users will be redirected to /login-error.

5. Controllers and Endpoints

Now let's have a look at our web controller mappings for the two applications. While they'll use the same endpoints, some implementations will differ.

5.1. Endpoints for View Rendering

For endpoints rendering the view, the implementations are the same:

@GetMapping("/") public String index() { return "index"; } @GetMapping("/login") public String showLoginPage() { return "login"; } @GetMapping("/home") public String getMeHome(Model model) { addUserAttributes(model); return "home"; }

Both our controller implementations, Shiro as well as Spring Security, return the index.ftl on the root endpoint, login.ftl on the login endpoint, and home.ftl on the home endpoint.

However, the definition of the method addUserAttributes at the /home endpoint will differ between the two controllers. This method introspects the currently logged in user's attributes.

Shiro provides a SecurityUtils#getSubject to retrieve the current Subject, and its roles and permissions:

private void addUserAttributes(Model model) { Subject currentUser = SecurityUtils.getSubject(); String permission = ""; if (currentUser.hasRole("ADMIN")) { model.addAttribute("role", "ADMIN"); } else if (currentUser.hasRole("USER")) { model.addAttribute("role", "USER"); } if (currentUser.isPermitted("READ")) { permission = permission + " READ"; } if (currentUser.isPermitted("WRITE")) { permission = permission + " WRITE"; } model.addAttribute("username", currentUser.getPrincipal()); model.addAttribute("permission", permission); }

On the other hand, Spring Security provides an Authentication object from its SecurityContextHolder‘s context for this purpose:

private void addUserAttributes(Model model) { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth != null && !auth.getClass().equals(AnonymousAuthenticationToken.class)) { User user = (User) auth.getPrincipal(); model.addAttribute("username", user.getUsername()); Collection authorities = user.getAuthorities(); for (GrantedAuthority authority : authorities) { if (authority.getAuthority().contains("USER")) { model.addAttribute("role", "USER"); model.addAttribute("permissions", "READ"); } else if (authority.getAuthority().contains("ADMIN")) { model.addAttribute("role", "ADMIN"); model.addAttribute("permissions", "READ WRITE"); } } } }

5.2. POST Login Endpoint

In Shiro, we map the credentials the user enters to a POJO:

public class UserCredentials { private String username; private String password; // getters and setters }

Then we'll create a UsernamePasswordToken to log the user, or Subject, in:

@PostMapping("/login") public String doLogin(HttpServletRequest req, UserCredentials credentials, RedirectAttributes attr) { Subject subject = SecurityUtils.getSubject(); if (!subject.isAuthenticated()) { UsernamePasswordToken token = new UsernamePasswordToken(credentials.getUsername(), credentials.getPassword()); try { subject.login(token); } catch (AuthenticationException ae) { logger.error(ae.getMessage()); attr.addFlashAttribute("error", "Invalid Credentials"); return "redirect:/login"; } } return "redirect:/home"; }

On the Spring Security side, this is just a matter of redirection to the home page. Spring's logging-in process, handled by its UsernamePasswordAuthenticationFilter, is transparent to us:

@PostMapping("/login") public String doLogin(HttpServletRequest req) { return "redirect:/home"; }

5.3. Admin-Only Endpoint

Now let's look at a scenario where we have to perform role-based access. Let's say we have an /admin endpoint, access to which should only be allowed for the ADMIN role.

Let's see how to do this in Shiro:

@GetMapping("/admin") public String adminOnly(ModelMap modelMap) { addUserAttributes(modelMap); Subject currentUser = SecurityUtils.getSubject(); if (currentUser.hasRole("ADMIN")) { modelMap.addAttribute("adminContent", "only admin can view this"); } return "home"; }

Here we extracted the currently logged in user, checked if they have the ADMIN role, and added content accordingly.

In Spring Security, there is no need for checking the role programmatically, we've already defined who can reach this endpoint in our SecurityConfig. So now, it's just a matter of adding business logic:

@GetMapping("/admin") public String adminOnly(HttpServletRequest req, Model model) { addUserAttributes(model); model.addAttribute("adminContent", "only admin can view this"); return "home"; }

5.4. Logout Endpoint

Finally, let's implement the logout endpoint.

In Shiro, we'll simply call Subject#logout:

@PostMapping("/logout") public String logout() { Subject subject = SecurityUtils.getSubject(); subject.logout(); return "redirect:/"; }

For Spring, we've not defined any mapping for logout. In this case, its default logout mechanism kicks in, which is automatically applied since we extended WebSecurityConfigurerAdapter in our configuration.

6. Apache Shiro vs Spring Security

Now that we've looked at the implementation differences, let's look at a few other aspects.

In terms of community support, the Spring Framework in general has a huge community of developers, actively involved in its development and usage. Since Spring Security is part of the umbrella, it must enjoy the same advantages. Shiro, though popular, does not have such humongous support.

Concerning documentation, Spring again is the winner.

However, there's a bit of a learning curve associated with Spring Security. Shiro, on the other hand, is easy to understand. For desktop applications, configuration via shiro.ini is all the easier.

But again, as we saw in our example snippets, Spring Security does a great job of keeping business logic and securityseparate and truly offers security as a cross-cutting concern.

7. Fazit

In diesem Tutorial haben wir Apache Shiro mit Spring Security verglichen .

Wir haben gerade die Oberfläche dessen, was diese Frameworks zu bieten haben, beweidet und es gibt viel zu erforschen. Es gibt einige Alternativen wie JAAS und OACC. Trotzdem scheint Spring Security mit seinen Vorteilen an diesem Punkt zu gewinnen.

Wie immer ist der Quellcode über GitHub verfügbar.