Spring Security 和 Apache Shiro

1、概览

在应用开发中,尤其是在企业级 Web 和移动应用领域,安全是一个首要问题。

本文将带你了解、比较两种流行的 Java 安全框架 - Apache ShiroSpring Security

2、背景

Apache Shiro 诞生于 2004 年,原名 JSecurity,2008 年被 Apache 基金会接受。迄今为止,它已发布了多个版本,最新版本为 1.13.0。

Spring Security 起源于 2003 年的 Acegi,在 2008 年首次公开发布时被纳入 Spring 框架。自诞生以来,它经历了多次迭代,目前的 GA 版本是 6.2.0。

这两种技术都提供身份认证和授权支持,以及加密和 Session 管理解决方案。此外,Spring Security 还提供了一流的保护,防范诸如 CSRF 和会话固定等攻击。

接下来,我们将通过使用 FreeMarker 的 Spring Boot 应用来演示如何使用这两种技术进行身份认证和授权。

3、配置 Apache Shiro

首先,来看看这两种框架的配置有何不同。

3.1、Maven 依赖

添加 shiro-spring-boot-web-startershiro-core 依赖:

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring-boot-web-starter</artifactId>
    <version>1.5.3</version>
</dependency>
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-core</artifactId>
    <version>1.5.3</version>
</dependency>

依赖的最新版本可在 Maven Central 上找到。

3.2、创建 Realm

要在内存中声明用户及其角色和权限,需要创建一个继承 Shiro JdbcRealm 的 Realm。

定义两个用户 - TomJerry,他们的角色分别是 USERADMIN

public class CustomRealm extends JdbcRealm {

    private Map<String, String> credentials = new HashMap<>();
    private Map<String, Set> roles = new HashMap<>();
    private Map<String, Set> 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")));
    }
}

接下来,需要覆写一些方法以启用对这些身份认证和授权信息的获取:

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

doGetAuthorizationInfo 方法使用了几个辅助方法来获取用户的角色和权限:

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

接下来,需要将 CustomRealm 作为一个 Bean 添加在 Spring Boot 应用中:

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

此外,还需要另一个 Bean 来为端点配置身份认证:

@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition() {
    DefaultShiroFilterChainDefinition filter = new DefaultShiroFilterChainDefinition();

    filter.addPathDefinition("/home", "authc");
    filter.addPathDefinition("/**", "anon");
    return filter;
}

如上,通过 DefaultShiroFilterChainDefinition 实例指定 /home 端点只能由通过身份认证的用户访问。

这就是所需的所有配置了,Shiro 会完成其余的工作。

4、配置 Spring Security

现在,来看看如何在 Spring 中实现同样的目标。

4.1、Maven 依赖

添加 spring-boot-starter-security 依赖:

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

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

依赖的最新版本可在 Maven Central 上找到。

4.2、配置类

接下来,在 SecurityConfig 类中定义 Spring Security 配置:

@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf()
            .disable()
            .authorizeRequests(authorize -> authorize.antMatchers("/index", "/login")
                .permitAll()
                .antMatchers("/home", "/logout")
                .authenticated()
                .antMatchers("/admin/**")
                .hasRole("ADMIN"))
            .formLogin(formLogin -> formLogin.loginPage("/login")
                .failureUrl("/login-error"));
        return http.build();
    }

    @Bean
    public InMemoryUserDetailsManager userDetailsService() throws Exception {
        UserDetails jerry = User.withUsername("Jerry")
            .password(passwordEncoder().encode("password"))
            .authorities("READ", "WRITE")
            .roles("ADMIN")
            .build();
        UserDetails tom = User.withUsername("Tom")
            .password(passwordEncoder().encode("password"))
            .authorities("READ")
            .roles("USER")
            .build();
        return new InMemoryUserDetailsManager(jerry, tom);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

如上,创建了一个 UserDetails 对象来声明用户及其角色和权限。此外,还使用 BCryptPasswordEncoder 对密码进行了编码。

Spring Security 还提供了一个 HttpSecurity 对象,用于进一步配置的 。在本例中:

  • 每个人都可以访问 indexlogin 页面
  • 只有通过身份认证的用户才能访问 homelogout
  • 只有具有 ADMIN 角色的用户才能访问 admin 页面

还定义了对基于表单的身份认证的支持,以便将用户重定向到登录端点。如果登录失败,用户将被重定向到 /login-error

5、Controller 和端点

现在来看一下这两个应用的 Web Controller。虽然它们使用相同的端点,但某些实现方式会有所不同。

5.1、渲染视图的端点

对于渲染视图的端点来说,实现方法是一样的:

@GetMapping("/")
public String index() {
    return "index";
}

@GetMapping("/login")
public String showLoginPage() {
    return "login";
}

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

Controller 实现(Shiro 和 Spring Security)都会在根端点上返回 index.ftl,在登录端点上返回 login.ftl,在主页端点上返回 home.ftl

然而,在 /home 端点中调用的 addUserAttributes 方法,其定义在两个 Controller 中有所不同。该方法会检查当前已登录用户的属性。

Shiro 提供了 SecurityUtils#getSubject 方法来检索当前 Subject 及其角色和权限:

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

而,Spring Security 提供了一个 Authentication 对象来获取角色和权限,可以从 SecurityContextHolder 的 Context 中获取该对象:

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<GrantedAuthority> 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、登录端点

在 Shiro 中将用户输入的凭证映射到 POJO 中:

public class UserCredentials {

    private String username;
    private String password;

    // GET、SET
}

然后,创建一个 UsernamePasswordToken 来登录用户或 Subject

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

对 Spring Security 来说,只重定向到主页即可。Spring 的登录过程由 UsernamePasswordAuthenticationFilter 处理的,对我们来说是透明的:

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

5.3、ADMIN 专用端点

现在来看看一个需要执行基于角色的访问控制的场景。假设有一个 /admin 端点,只有具有 ADMIN 角色的用户才能访问。

使用 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";
}

如上,提取了当前登录的用户,检查他们是否拥有 ADMIN 角色,并添加了相应的内容。

在 Spring Security 中,无需以编程方式检查角色。我们已经在 SecurityConfig 中定义了谁可以访问该端点。因此,现在只需添加业务逻辑即可:

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

5.4、注销端点

最后,实现注销端点。

在 Shiro 中,只需调用 Subject#logout

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

对于 Spring,我们没有为注销定义任何映射。在这种情况下,由于我们在配置中创建了 SecurityFilterChain Bean,因此会自动应用其默认注销机制。

6、Apache Shiro 和 Spring Security 的对比

了解了实现方面的差异后,来看看其他几个方面。

在社区支持方面,Spring 拥有庞大的开发人员社区,他们积极参与了 Spring 的开发和使用。既然 Spring Security 是该框架的一部分,那么它肯定也享有同样的优势。Shiro 虽然很受欢迎,但却没有如此巨大的支持。

在文档方面,Spring 再次胜出。

不过,Spring Security 有一定的学习曲线。而 Shiro 则简单易懂。对于桌面应用程序来说,通过 shiro.ini 进行配置更加容易。

但是,正如示例片段中所示,Spring Security 能很好地将业务逻辑和安全配置分离开来。

7、总结

本文通过实际案例介绍了 Apache Shiro 和 Spring Security 在认证和授权方面的差异,还对它们进行了对比。


Ref:https://www.baeldung.com/spring-security-vs-apache-shiro