框架搭建

1.基础环境

jdk8
maven
lombok
spring boot 2.5.7

2.导入shiro maven坐标

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

3.新建自定义Realm类,实现认证与鉴权核心逻辑

创建UserInfo.java:

@Setter
@Getter
public class UserInfo implements Serializable {
    private String username;
    private String password;
    private Set<String> roles;
    private Set<String> perms;
}

创建CustomRealm .java:

import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;

import java.util.HashSet;

public class CustomRealm extends AuthorizingRealm {
    /**
     * 身份认证
     * 主要作用是提供一个身份的鉴定功能,基本思路是,从数据库中查找用户身份信息,交给Shiro框架,shiro框架会自动与登录页传进来的账号信息进行对比是否匹配,如果匹配,则登录成功,否则登录失败
     * @param authenticationToken
     * @return
     * @throws AuthenticationException
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        //登录TOKEN,包含了用户账号密码
        UsernamePasswordToken upToken = (UsernamePasswordToken) authenticationToken;
        String username = upToken.getUsername();
        //下列多个判断可根据业务自行增删
        // 判断用户名是否不存在,如果不存在抛出异常
        if (username == null) {
            throw new AccountException("Null usernames are not allowed by this realm.");
        }
		
		//模拟数据,可通自行通过查找数据库获取当前用户信息
        UserInfo user = new UserInfo();
        user.setUsername("aesop");
        user.setPassword("123");

        //查询用户的角色和权限存到SimpleAuthenticationInfo中,这样在其它地方
        //SecurityUtils.getSubject().getPrincipal() 就能拿出用户的所有信息,包括角色和权限

        /** 将用户权限和角色存入User对象*/
        HashSet<String> roles = new HashSet<>();
        roles.add("admin");
        roles.add("teacher");
        user.setRoles(roles);

        HashSet<String> perms = new HashSet<>();
        perms.add("blog:read");
        perms.add("blog:search");
        user.setPerms(perms);

        //也可存入额外的信息到Session
     	//SecurityUtils.getSubject().getSession().setAttribute(Constants.SESSION_USER_INFO, userInfo);


        //构造验证信息返回
        SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user, user.getPassword(), getName());
        return info;
    }

    /**
     * 授权
     * 身份鉴定完毕后,把权限赋予给当前用户,以便后续在需要的地方根据权限细致控制
     * @param principals
     * @return
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        //null usernames are invalid
        if (principals == null) {
            throw new AuthorizationException("PrincipalCollection method argument cannot be null.");
        }
        //获取当前用户对应的User对象
        UserInfo user = (UserInfo) getAvailablePrincipal(principals);
        //创建权限对象
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        //设置用户角色(user.getRoles()是一个Set<String>,【admin,student。。。】)
        info.setRoles(user.getRoles());
        //设置用户许可(user.getPerms()是一个Set<String>,【blog:read,blog:search。。。】)
        info.setStringPermissions(user.getPerms());
        return info;
    }
}

3. 添加Shiro拦截配置

创建ShiroConfig.java:

package com.example.springshirodemo.config.shiro;

import org.apache.shiro.session.mgt.eis.SessionDAO;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;

import javax.servlet.Filter;
import java.util.HashMap;
import java.util.Map;

/**
 * Shiro核心配置
 */
@Configuration
public class ShiroConfig {
    /**
     * shiro的统一权限判定
     * 根据业务需要对权限进行拦截或放行, anon:所有请求可访问,  authc: 需要登录认证后才能访问
     * @return
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean() {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager(myRealm()));

        Map<String, Filter> filters = new HashMap<>();
        filters.put("authc", new LoginFormFilter());
        shiroFilterFactoryBean.setFilters(filters);
        Map<String, String> map = new HashMap<>();
        // 登入登出
        map.put("/doLogin", "anon");
        map.put("/logout", "logout");
        // swagger
        map.put("/swagger**/**", "anon");
        map.put("/webjars/**", "anon");
        map.put("/v2/**", "anon");
        // 对所有用户认证
        map.put("/**", "authc");
        // 未登录,重定向路径
        // shiroFilterFactoryBean.setLoginUrl("/login");
        // 首页
        // shiroFilterFactoryBean.setSuccessUrl("/index");
        // 错误页面,认证不通过跳转
        // shiroFilterFactoryBean.setUnauthorizedUrl("/error");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(map);
        return shiroFilterFactoryBean;
    }

    /**
     * 将自定义CustomRealm 注入进SecurityManager
     * @return
     */
    @Bean
    public CustomRealm myRealm() {
        return new CustomRealm();
    }

    /**
     * 将自定义CustomRealm 注入进SecurityManager
     * Shiro通过SecurityManager来管理内部组件实例,并通过它来提供安全管理的各种服务
     * @return
     */
    @Bean
    public DefaultWebSecurityManager securityManager(CustomRealm customRealm) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        // 自定义Realm
        securityManager.setRealm(customRealm);
        return securityManager;
    }
}

自定义登录失败、或没有登陆时返回json格式,而不是重定向到login.jsp页面。注意:配置了这个之后,重定向路径配置setLoginUrl将失效
创建ShiroLoginFilter类:

import cn.aesop.common.restful.ResultBean;
import cn.aesop.common.restful.ResultCode;
import com.alibaba.fastjson.JSON;
import org.apache.shiro.web.filter.authc.FormAuthenticationFilter;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;

/**
 * @author: hxy
 * @description: 对没有登录的请求进行拦截, 全部返回json信息. 覆盖掉shiro原本的跳转login.jsp的拦截方式
 * @date: 2017/10/24 10:11
 */
public class ShiroLoginFilter extends FormAuthenticationFilter {

	@Override
	protected boolean onAccessDenied(ServletRequest request, ServletResponse response) {
		PrintWriter out = null;
		HttpServletResponse res = (HttpServletResponse) response;
		try {
			res.setCharacterEncoding("UTF-8");
			res.setContentType("application/json");
			out = response.getWriter();
			out.println(JSON.toJSONString(ResultBean.FAIL(ResultCode.E_201)));
		} catch (Exception e) {
		} finally {
			if (null != out) {
				out.flush();
				out.close();
			}
		}
		return false;
	}
}

4.注解权限

在Controller接口上加上如下注解,即可拦截没有权限的请求

@RequiresRoles(value={"admin","user"},logical = Logical.OR)
 @RequiresPermissions(value={"add","update"},logical = Logical.AND)

如果有多个权限/角色验证的时候中间用“,”隔开,默认是所有列出的权限/角色必须同时满足才生效。但是在注解中有logical = Logical.OR这块。这里可以让权限控制更灵活些。

如果将这里设置成OR,表示所列出的条件只要满足其中一个就可以,如果不写或者设置成logical = Logical.AND,表示所有列出的都必须满足才能进入方法。

用subject这种通过代码控制的方法我没有深入了解,所以没有找到这种权限的控制。再加上使用注解更加简洁明了,所以个人更倾向于使用注解方式来控制。

至此一个基本的shrio + spring boot的框架已经搭建完毕

5.获取上下文信息

登录成功后可以通过以下代码获取当前登录的用户信息

Subject currentUser = SecurityUtils.getSubject();
UserInfo principal = (UserInfo)currentUser.getPrincipal();

//或者从session中获取自定义的信息
//Session session = SecurityUtils.getSubject().getSession();
//UserInfo principal = (UserInfo) session.getAttribute(Constants.SESSION_USER_INFO);

5. 密码加密

上面的例子密码是直接明文保存在数据库的,不安全,需要进行加密后才能存储,并且要与身份认证形成一个体系,下面介绍基本修改步骤:
1) 创建凭证匹配器

/**
 * 凭证匹配器
 * (由于我们的密码校验交给Shiro的SimpleAuthenticationInfo进行处理了
 * 所以我们需要修改下doGetAuthenticationInfo中的代码;
 * )
 * 可以扩展凭证匹配器,实现 输入密码错误次数后锁定等功能,下一次
 */
@Bean(name = "credentialsMatcher")
public HashedCredentialsMatcher hashedCredentialsMatcher() {
	HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
	//散列算法:这里使用MD5算法;
	hashedCredentialsMatcher.setHashAlgorithmName("md5");
	//散列的次数,比如散列两次,相当于 md5(md5(""));
	hashedCredentialsMatcher.setHashIterations(2);
	//storedCredentialsHexEncoded默认是true,此时用的是密码加密用的是Hex编码;false时用Base64编码
	hashedCredentialsMatcher.setStoredCredentialsHexEncoded(true);
	return hashedCredentialsMatcher;
}

2)在注入CustomRealm处设置凭证匹配器,修改代码如下

/**
 * 注入自定义权限验证对象
 * Shiro Realm 继承自AuthorizingRealm的自定义Realm,即指定Shiro验证用户登录的类为自定义的
 */
@Bean
public CustomerRealm userRealm() {
	CustomerRealm realm = new CustomerRealm();
	realm.setCredentialsMatcher(hashedCredentialsMatcher());
	return realm;
}

3)修改CustomerRealm类的doGetAuthenticationInfo方法

...

//加入盐 salt=username+salt
SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user, user.getPassword(), ByteSource.Util.bytes(username+"salt"), getName());
...

4)在注册用户或创建密码时,使用以下规则创建加密密码,存入数据库

// md5 + salt + hash散列次数
Md5Hash md5Hash2 = new Md5Hash(password, username+"salt", 2);
return md5Hash2.toString();

参考:shiro使用Md5加密

6. session持久化、分布式session共享

将session保存到redis ,多机部署使用同一个redis,可以保证session互相共享; 系统重启,用户也无需重新登陆
1)maven pom加入redis

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

2)application.yml配置

spring:
  redis:
    host: localhost #redis服务PI
    port: 6379      #服务端

Redis 的基本操作

@Autowired
private RedisTemplate<String, Object> redisTemplate;
//保存
redisTemplate.opsForValue().set("key-1", "value-1"); 
//带有效期的保存
redisTemplate.opsForValue().set("key-1", "value-1", 120, TimeUnit.SECONDS);
//删除
redisTemplate.delete("key-1");

3)创建类继承CachingSessionDAO,自定义session持久化实现
需要Override的4个方法是:
doCreate: shiro创建session时,将session保存到redis
doUpdate: 当用户维持会话时,刷新session的有效时间
doDelete: 当用户注销或会话过期时,将session从redis中删除
doReadSession: shiro通过sessionId获取Session对象,从redis中获取

创建 RedisSessionDAO.java

import org.apache.shiro.session.Session;
import org.apache.shiro.session.mgt.eis.CachingSessionDAO;
import org.springframework.data.redis.core.RedisTemplate;

import java.io.Serializable;
import java.util.concurrent.TimeUnit;

/**
 * 自定义session持久化实现,针对集群共享进行的Shiro 扩展
 */
public class RedisSessionDAO extends CachingSessionDAO {
    //存入Redis中的SessionID的前缀
    private static final String PREFIX = "SENTGON_SHOP_SHIRO_SESSION_ID";
    //有效期(后续使用时会增加时间单位,秒)
    private static final int EXPRIE = 86400; //1天
    //Redis 操作工具 
    private RedisTemplate<Serializable, Session> redisTemplate;

    //构造函数
    public RedisSessionDAO(RedisTemplate<Serializable, Session> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }


    /**
     * shiro创建session时,将session保存到redis
     * @param session
     * @return
     */
    @Override
    protected Serializable doCreate(Session session) {
        //生成SessionID
        Serializable serializable = this.generateSessionId(session);
        assignSessionId(session, serializable);
        //将sessionid作为Key,session作为value存入redis
        redisTemplate.opsForValue().set(PREFIX+serializable, session);
        return serializable;
    }

    /**
     * 当用户维持会话时,刷新session的有效时间
     * @param session
     */
    @Override
    protected void doUpdate(Session session) {
        //设置session有效期
        session.setTimeout(EXPRIE * 1000);
        //将sessionid作为Key,session作为value存入redis,并设置有效期
        redisTemplate.opsForValue().set(PREFIX+session.getId(), session, EXPRIE, TimeUnit.SECONDS);
    }

    /**
     * 当用户注销或会话过期时,将session从redis中删除
     * @param session
     */
    @Override
    protected void doDelete(Session session) {
        //null 验证
        if (session == null) {
            return;
        }
        //从Redis中删除指定SessionId的k-v
        redisTemplate.delete(PREFIX+session.getId());
    }

    /**
     *  shiro通过sessionId获取Session对象,从redis中获取
     * @param sessionId
     * @return
     */
    @Override
    protected Session doReadSession(Serializable sessionId) {
        if (sessionId == null) {
            return null;
        }
        //从Redis中读取Session对象
        Session session = redisTemplate.opsForValue().get(PREFIX+sessionId);
        return session;
    }
}

4)将RedisSessionManager注入 SecurityManager

@Autowired
private RedisTemplate redisTemplate;

/**
 * 容器中注册RedisSessionDao
 * @param redisTemplate
 * @return
 */
@Bean
public SessionDAO redisSessionDAO(RedisTemplate redisTemplate) {
	return new RedisSessionDAO(redisTemplate);
}

/**
 * 将自定义CustomRealm 注入进SecurityManager
 * Shiro通过SecurityManager来管理内部组件实例,并通过它来提供安全管理的各种服务
 * @return
 */
@Bean
public DefaultWebSecurityManager securityManager(CustomerRealm customRealm) {
	DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
	// 自定义Realm
	securityManager.setRealm(customRealm);
	// 重写session管理器,注入自定义的SessionDao
	DefaultWebSessionManager defaultWebSessionManager = new DefaultWebSessionManager();
	defaultWebSessionManager.setSessionDAO(redisSessionDAO(redisTemplate));
	securityManager.setSessionManager(defaultWebSessionManager);
	return securityManager;
}

至此,已经完成Shiro的集群共享Session