浅谈Shiro框架在Spring Boot中的认证应用

点击上方蓝字关注我们!

通常,公司的项目都会有严格的认证和授权操作,在Java开发领域常见的安全框架有Shiro和Spring Security。Apache Shiro是一个开源的轻量级Java安全管理框架,提供认证、授权、密码管理、缓存管理等功能,相对于Spring Security框架更加直观,易用,同时也能提供健壮的安全性。

对于Spring Boot项目,Shiro官方提供了shiro-spring-boot-web-starter来简化Shiro在Spring Boot中的配置,不需要手动整合。

Shiro 核心组件

Shiro有三大核心组件,即Subject,SecurityManager和Realm,如图所示:

Spring Boot 整合 Shiro

1. 管理shiro版本号

<properties>
    <shiro.version>1.6.0</shiro.version>
    <java.version>1.8</java.version>
    <jmeter.version>5.4.1</jmeter.version>
</properties>

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring-boot-web-starter</artifactId>
    <version>${shiro.version}</version>
</dependency>

3. ShiroConfig类

①. 创建ShiroConfig配置类,并添加注解@Configuration

②. 在配置类中创建3个Bean,ShiroFilterFactoryBean、DefaultWebSecurityManager和 Realm

3.1 创建Realm Bean

Realm Bean是ShiroConfig配置类中的第1个Bean,此处只展示一个LdapReam Bean。注解@DependsOn表示组件依赖,下图中表示依赖lifecycleBeanPostProcessor。LifecycleBeanPostProcessor用来管理shiro Bean的生命周期,在LdapReam创建之前先创建lifecycleBeanPostProcessor。

3.2在ShiroConfig中添加SecurityManager配置

Shiro通过SecurityManager来管理内部组件实例,并通过它来提供安全管理的各种服务。modularRealmAuthenticator是shiro提供的realm管理器,用来设置realm生效, 通过setAuthenticationStrategy来设置多个realm存在时的生效规则。

@Bean(name = "securityManager")
public DefaultWebSecurityManager securityManager(SessionManager sessionManager, MemoryConstrainedCacheManager memoryConstrainedCacheManager) {
    DefaultWebSecurityManager dwsm = new DefaultWebSecurityManager();
    dwsm.setSessionManager(sessionManager);
    dwsm.setCacheManager(memoryConstrainedCacheManager);
    dwsm.setAuthenticator(modularRealmAuthenticator());
    return dwsm;
}

重写ModularRealmAuthenticator,只要有一个Realm验证成功即可,只返回第一个Realm身份验证成功的认证信息。

@Bean
public ModularRealmAuthenticator modularRealmAuthenticator() {
    UserModularRealmAuthenticator modularRealmAuthenticator = new UserModularRealmAuthenticator();
    modularRealmAuthenticator.setAuthenticationStrategy(new FirstSuccessfulStrategy());
    return modularRealmAuthenticator;
}

①. 构建ShiroFilterFactoryBean对象,用于创建过滤工厂

@Bean
public ShiroFilterFactoryBean getShiroFilterFactoryBean(DefaultWebSecurityManager sessionManager) {
//构建ShiroFilterFactoryBean对象,负责创建过滤器工厂
    ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
//设置登录路径
    shiroFilterFactoryBean.setLoginUrl("/login");    
//注意:必须设置SecuritManager
shiroFilterFactoryBean.setSecurityManager(sessionManager);
//设置访问未授权的需要跳转到的路径
    shiroFilterFactoryBean.setUnauthorizedUrl("/403");
//设置登录成功访问路径
    shiroFilterFactoryBean.setSuccessUrl("/");
//自定义的过滤设置注入到shiroFilter中
    shiroFilterFactoryBean.getFilters().put("apikey", new ApiKeyFilter());
    shiroFilterFactoryBean.getFilters().put("csrf", new CsrfFilter());
    shiroFilterFactoryBean.getFilters().put("user", new UserAuthcFilter());
//定义map指定请求过滤规则
    Map<String, String> filterChainDefinitionMap = shiroFilterFactoryBean.getFilterChainDefinitionMap();
    ShiroUtils.loadBaseFilterChain(filterChainDefinitionMap);
    ShiroUtils.ignoreCsrfFilter(filterChainDefinitionMap);
    filterChainDefinitionMap.put("/**", "apikey, csrf, authc");
    return shiroFilterFactoryBean;
}

Shiro有两种方式可进行精度控制,一种是过滤器方式,根据访问的URL进行控制,该种方式允许使用*匹配URL,可以进行粗粒度控制;另一种是注解的方式,实现细粒度控制,但只能是在方法上控制,无法控制类级别访问。本文将使用第一种方式编写过滤器文件。

过滤器的类型有很多,本文代码只用到anon和authc两种类型。

定义一个Map类型的filterChainDefinitionMap,使用ShiroFilterChainDefinition来控制请求路径的鉴权与授权。

创建ShiroUtils类,自定义静态方法loadBaseFilterChain()和ignoreCsrfFilter()方法,判断哪些请求路径需要用户登录才能访问,哪些不需要登录就能访问,实现粗粒度控制。

关键代码(节选):

public static void loadBaseFilterChain(Map<String, String> filterChainDefinitionMap){
        filterChainDefinitionMap.put("/resource/**", "anon");
        filterChainDefinitionMap.put("/*.worker.js", "anon");
        filterChainDefinitionMap.put("/login", "anon");
        filterChainDefinitionMap.put("/signin", "anon");
}

IgnoreCsrfFilter()方法定义的是authc类型的过滤设置,authc表示只有登录后才有权限访问。

public static void ignoreCsrfFilter(Map<String, String> filterChainDefinitionMap) {
    filterChainDefinitionMap.put("/", "apikey, authc"); // 跳转到 / 不用校验 csrf
    filterChainDefinitionMap.put("/language", "apikey, authc");// 跳转到 /language 不用校验 csrf
    filterChainDefinitionMap.put("/test/case/file/preview/**", "apikey, authc"); // 预览测试用例附件 不用校验 csrf
}

@EventListener
public void handleContextRefresh(ContextRefreshedEvent event) {
    ApplicationContext context = event.getApplicationContext();
    List<Realm> realmList = new ArrayList<>();
    LocalRealm localRealm = context.getBean(LocalRealm.class);
    LdapRealm ldapRealm = context.getBean(LdapRealm.class);
    realmList.add(localRealm);
    realmList.add(ldapRealm);
context.getBean(DefaultWebSecurityManager.class).setRealms(realmList);
}

4. 自定义LdapRealm

Realm可由Shiro提供,也可以自定义。自定义Realm一般继承AuthorizingRealm,然后实现getAuthenticationInfo()和getAuthorizationInfo()方法,来完成身份认证和权限获取。

/**
 * 登录认证
 */
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
//构造一个UsernamePasswordToken
    UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
    String userId = token.getUsername();
    String password = String.valueOf(token.getPassword());
    return loginLdapMode(userId, password);
}

在loginLdapMode()方法中,通过传过来的userId调用userService里的方法获取user,然后对user进行判断,若通过验证,返回一个AuthenticationInfo实现。

private AuthenticationInfo loginLdapMode(String userId, String password) {
    String email = (String) SecurityUtils.getSubject().getSession().getAttribute("email");
    UserDTO user = userService.getLoginUser(userId, Arrays.asList(UserSource.LDAP.name(), UserSource.LOCAL.name()));
    if (user == null) {
        user = userService.getUserDTOByEmail(email, UserSource.LDAP.name(), UserSource.LOCAL.name());
        if (user == null) {
            throw new UnknownAccountException(Translator.get("user_not_exist") + userId);
        }
        userId = user.getId();
    }
SessionUser sessionUser = SessionUser.fromUser(user);
    SessionUtils.putUser(sessionUser);
    return new SimpleAuthenticationInfo(userId, password, getName());
}

doGetAuthorizationInfo()则用于获取权限相关信息,PrincipalCollection 是一个身份集合。首先通过getPrimaryPrincipal()得到传入的用户名,然后调用getAuthorizationInfo()方法,再根据用户名调用 UserService接口获取角色及权限信息,并将得到的用户roles放到authorizationInfo中,并返回。

/**
 * 授权
 */
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
    String userId = (String) principals.getPrimaryPrincipal();
    return getAuthorizationInfo(userId, userService);
}
public static AuthorizationInfo getAuthorizationInfo(String userId, UserService userService) {
    SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
    UserDTO userDTO = userService.getUserDTO(userId);
    Set<String> roles = userDTO.getRoles().stream().map(Role::getId).collect(Collectors.toSet());
    authorizationInfo.setRoles(roles);
    return authorizationInfo;
}

应用案例-登录认证

1. 流程分析

结合上面Shiro框架在Spring Boot中关键配置,梳理了一下登录认证的流程分析图。

客户端提交用户账号和密码,在Controller中拿到账号和密码封装到token对象,然后借助subject的login方法,把数据提交给SecurityManager,使用Authenticator处理token,Authenticator从Realm列表中获取LdapRealm,LdapRealm从token中获取数据,交给authenticate进行比对,对比通过返回AuthenticationInfo。

2. 登录实现

@PostMapping(value = "/signin")
public ResultHolder login(@RequestBody LoginRequest request) {
    SessionUser sessionUser = SessionUtils.getUser();
    if (sessionUser != null) {
        if (!StringUtils.equals(sessionUser.getId(), request.getUsername())) {
            return ResultHolder.error(Translator.get("please_logout_current_user"));
        }
    }   SecurityUtils.getSubject().getSession().setAttribute("authenticate", UserSource.LOCAL.name());
    return userService.login(request);
}

在login方法中,把用户名和密码封装为UsernamePasswordToken对象token,然后通过SecurityUtils.getSubject()获取Subject对象,并将前面获取token对象作为参数。若调用subject.login(token)时不抛出任何异常,说明认证通过,调用subject.isAuthenticated()返回true表示当前的用户已经登录。后续可以根据subject实例获取用户信息。

public ResultHolder login(LoginRequest request) {
        String login = (String) SecurityUtils.getSubject().getSession().getAttribute("authenticate");
        String username = StringUtils.trim(request.getUsername());
        String password = "";
        if (!StringUtils.equals(login, UserSource.LDAP.name())) {
            password = StringUtils.trim(request.getPassword());
            ……
        }
        UsernamePasswordToken token = new UsernamePasswordToken (username, password, login);
        Subject subject = SecurityUtils.getSubject();
        try {
            subject.login(token);
            if (subject.isAuthenticated()) {
                UserDTO user = (UserDTO) subject.getSession().getAttribute(ATTR_USER);
               ……
                                return ResultHolder.success(subject.getSession().getAttribute("user"));
} else {
        return ResultHolder.error(Translator.get("login_fail"));
    }
} catch (ExcessiveAttemptsException e) {
    throw new ExcessiveAttemptsException(Translator.get("excessive_attempts"));
}
……
}

总结

Apache Shiro 是一个功能强大且灵活的开源安全框架,它可以很好地处理身份认证、授权、企业会话管理等,简单易用,可以使项目的验证架构更加完善。本文演示Spring Boot集成Shiro框架,从身份认证和授权的配置情况进行说明,并演示了基础的身份验证功能,如有不足,请多指教。