Spring Security contre Apache Shiro

1. Vue d'ensemble

La sécurité est une préoccupation majeure dans le monde du développement d'applications, en particulier dans le domaine des applications Web et mobiles d'entreprise.

Dans ce tutoriel rapide, nous comparerons deux frameworks de sécurité Java populaires - Apache Shiro et Spring Security .

2. Un peu de contexte

Apache Shiro est né en 2004 sous le nom de JSecurity et a été accepté par la Fondation Apache en 2008. À ce jour, il a vu de nombreuses versions, la dernière à ce jour est la 1.5.3.

Spring Security a débuté sous le nom d'Acegi en 2003 et a été incorporé dans Spring Framework avec sa première version publique en 2008. Depuis sa création, il a subi plusieurs itérations et la version actuelle de GA au moment de l'écriture est 5.3.2.

Les deux technologies offrent un support d'authentification et d'autorisation ainsi que des solutions de cryptographie et de gestion de session . De plus, Spring Security offre une protection de premier ordre contre les attaques telles que CSRF et la fixation de session.

Dans les prochaines sections, nous verrons des exemples de la manière dont les deux technologies gèrent l'authentification et l'autorisation. Pour simplifier les choses, nous utiliserons des applications MVC de base basées sur Spring Boot avec des modèles FreeMarker.

3. Configuration d'Apache Shiro

Pour commencer, voyons comment les configurations diffèrent entre les deux frameworks.

3.1. Dépendances de Maven

Puisque nous utiliserons Shiro dans une application Spring Boot, nous aurons besoin de son démarreur et du module shiro-core :

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

Les dernières versions sont disponibles sur Maven Central.

3.2. Créer un royaume

Pour déclarer les utilisateurs avec leurs rôles et autorisations en mémoire, nous devons créer un royaume étendant le JdbcRealm de Shiro . Nous définirons deux utilisateurs - Tom et Jerry, avec les rôles USER et ADMIN, respectivement:

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"))); } }

Ensuite, pour permettre la récupération de cette authentification et autorisation, nous devons remplacer quelques méthodes:

@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; } 

La méthode doGetAuthorizationInfo utilise quelques méthodes d'assistance pour obtenir les rôles et les autorisations de l'utilisateur:

@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; } 

Ensuite, nous devons inclure ce CustomRealm en tant que bean dans notre application de démarrage:

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

De plus, pour configurer l'authentification pour nos points de terminaison, nous avons besoin d'un autre bean:

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

Ici, en utilisant une instance DefaultShiroFilterChainDefinition , nous avons spécifié que notre point de terminaison / home ne peut être accédé que par des utilisateurs authentifiés.

C'est tout ce dont nous avons besoin pour la configuration, Shiro fait le reste pour nous.

4. Configuration de Spring Security

Voyons maintenant comment réaliser la même chose au printemps.

4.1. Dépendances de Maven

Tout d'abord, les dépendances:

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

Les dernières versions sont disponibles sur Maven Central.

4.2. Classe de configuration

Ensuite, nous définirons notre configuration Spring Security dans une classe SecurityConfig , étendant 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(); } } 

Comme nous pouvons le voir, nous avons créé un objet AuthenticationManagerBuilder pour déclarer nos utilisateurs avec leurs rôles et leurs autorités. De plus, nous avons encodé les mots de passe à l'aide d'un BCryptPasswordEncoder .

Spring Security nous fournit également son objet HttpSecurity pour d'autres configurations. Pour notre exemple, nous avons autorisé:

  • tout le monde pour accéder à nos pages d' index et de connexion
  • seuls les utilisateurs authentifiés pour accéder à la page d' accueil et se déconnecter
  • seuls les utilisateurs ayant le rôle ADMIN pour accéder aux pages d' administration

Nous avons également défini la prise en charge de l'authentification par formulaire pour envoyer les utilisateurs vers le point de terminaison de connexion . En cas d'échec de la connexion, nos utilisateurs seront redirigés vers / login-error .

5. Contrôleurs et terminaux

Jetons maintenant un œil à nos mappages de contrôleurs Web pour les deux applications. Bien qu'ils utilisent les mêmes points de terminaison, certaines implémentations seront différentes.

5.1. Points de terminaison pour le rendu de vue

Pour les points de terminaison rendant la vue, les implémentations sont les mêmes:

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

Les implémentations de nos contrôleurs, Shiro ainsi que Spring Security, renvoient l' index.ftl sur le point de terminaison racine, login.ftl sur le point de terminaison de connexion et home.ftl sur le point de terminaison domestique.

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. Conclusion

Dans ce didacticiel, nous avons comparé Apache Shiro à Spring Security .

Nous venons de parcourir la surface de ce que ces cadres ont à offrir et il y a beaucoup à explorer davantage. Il existe de nombreuses alternatives telles que JAAS et OACC. Pourtant, avec ses avantages, Spring Security semble gagner à ce stade.

Comme toujours, le code source est disponible sur sur GitHub.