一直想好好的学习一下安全方面的框架,自己对于这个方面的知识很欠缺,借助当前公司项目的机会,认真的研究了两天Shiro这个高度可定制的框架(虽然在Spring Boot中集成Spring Security更为方便,但是看了一天的相关资料,感觉还是Shiro更简单,以后再认真学习Spring Security)。
项目数据库使用的是MongoDB,稍后使用MySQL做一个集成,毕竟用到MongoDB的不多,其实都是大同小异,不过在SpringBoot中使用MongoDB特别方便。
1 准备
- Spring Boot 2.x
- Shiro
- Redis
- Maven 3.6
- JDK 1.8
- IDEA
2 Shiro介绍
- Shiro中四大模块如图:
- Authentication,身份证认证,一般就是登录
- Authorization,授权,给用户分配角色或者访问某些资源的权限
- Session Management, 用户的会话管理员,多数情况下是web session
- Cryptography, 数据加解密,比如密码加解密等
Shiro权限控制流程及相关概念(http://shiro.apache.org/architecture.html),其中:
- Subject:主体,如用户或程序,主体去访问系统或者资源
- SecurityManager:安全管理器,Subject的认证和授权都需在安全管理器下进行
- Authenticator:认证器,主要负责Subject的认证
- Realm:数据域,Shiro和安全数据的连接器,类似于jdbc连接数据库; 通过realm获取认证授权相关信息,在集成时这个部分需要重写Shiro指定的方法
- Authorizer:授权器,主要负责Subject的授权, 控制subject拥有的角色或者权限
- Cryptography:加解密,Shiro中包含易于使用和理解的数据加解密方法,简化了很多复杂的api
- Cache Manager:缓存管理器,比如认证或授权信息,通过缓存进行管理,提高性能
3 集成Shiro
3.1 数据库设计
3.2 创建项目
使用IDEA创建一个空的Spring Boot项目,勾选相关web依赖和数据库依赖,这里把相关依赖全部罗列出来
<properties>
<java.version>1.8</java.version>
<shiro-spring.version>1.5.1</shiro-spring.version>
<shiro-redis.version>3.2.3</shiro-redis.version>
<druid.version>1.1.20</druid.version>
<fastjson.version>1.2.67</fastjson.version>
<mybatis-plus.version>3.3.0</mybatis-plus.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- FastJson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>${fastjson.version}</version>
</dependency>
<!--mysql-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- druid数据库连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>${druid.version}</version>
</dependency>
<!--mybatis-plus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!--整合shiro-->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>${shiro-spring.version}</version>
</dependency>
<!--整合shiro-redis缓存插件-->
<dependency>
<groupId>org.crazycake</groupId>
<artifactId>shiro-redis</artifactId>
<version>${shiro-redis.version}</version>
<exclusions>
<exclusion>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
3.3 配置application.yml文件
配置数据库连接相关信息
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/db_role?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=UTC
username: db_role
password: db_role
redis:
database: 1 # Redis数据库索引(默认为0)
host: 192.168.213.146 # Redis服务器地址
port: 6379 # Redis服务器连接端口
password: coctrl # Redis服务器连接密码(默认为空)
timeout: 0 # 连接超时时间(毫秒)
jedis:
pool:
max-active: 8
max-idle: 8
max-wait: -1ms
min-idle: 0
# SQL打印
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
3.4 集成mybatis-plus
3.4.1 entity
实体类与数据库一一对应就行,可以配合mybatis-plus直接自动生成
3.4.2 mapper
- UserMapper.java
只写了一个用于登录是通过用户名查对应信息的方法
@Component
public interface UserMapper extends BaseMapper<User> {
/**
* 根据用户名查询用户信息,登录
* @param username
* @return
*/
UserVO findUserInfoByUsername(String username);
}
UserVO是一个自定义的实体类,用于封装用户、角色、权限信息,具体可以看项目源码
- UserMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.kangaroohy.shiroredis.mapper.UserMapper">
<resultMap id="userInfoMap" type="com.kangaroohy.shiroredis.domain.entity.vo.UserVO" >
<id property="id" column="u_id" />
<result property="username" column="u_username"/>
<result property="password" column="u_password"/>
<result property="gender" column="u_gender"/>
<result property="updateTime" column="u_updateTime"/>
<collection property="roleList" ofType="com.kangaroohy.shiroredis.domain.entity.vo.RoleVO" >
<id property="role.id" column="r_id" />
<result property="role.name" column="r_name"/>
<result property="role.description" column="r_description"/>
<collection property="permissionList" ofType="com.kangaroohy.shiroredis.domain.entity.po.Permission" >
<id property="id" column="p_id" />
<result property="name" column="p_name"/>
<result property="url" column="p_url"/>
</collection>
</collection>
</resultMap>
<select id="findUserInfoByUsername" resultMap="userInfoMap">
SELECT
u.id u_id,
u.username u_username,
u.password u_password,
u.gender u_gender,
u.update_time u_updateTime,
r.id r_id,
r.name r_name,
r.description r_description,
p.id p_id,
p.name p_name,
p.url p_url
FROM
t_user u
LEFT JOIN t_user_role ur ON ur.uid = u.id
LEFT JOIN t_role r ON r.id = ur.rid
LEFT JOIN t_role_permission rp ON rp.rid = r.id
LEFT JOIN t_permission p ON p.id = rp.pid
WHERE
u.username = #{username}
</select>
</mapper>
3.4.3 service
这个地方就是一个简单的调用mapper中的方法,具体可以看源码
不要忘记启动器类配置mybatis的mapper扫描路径,这个集成mybatis-plus的时候应该有配置
@MapperScan("com.kangaroohy.shiroredis.mapper")
3.5 配置Shiro
3.5.1 自定义Realm:CustomRealm.java
@Slf4j
public class CustomRealm extends AuthorizingRealm {
@Autowired
private UserService userService;
/**
* 授权时调用(权限校验)
*
* @param principalCollection
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
//获得当前对象
UserVO user = (UserVO) principalCollection.getPrimaryPrincipal();
log.info("用户 {} 调用了 doGetAuthorizationInfo 进行授权", user.getUsername());
UserVO info = userService.findUserInfoByUsername(user.getUsername());
Set<String> stringRolesList = new HashSet<>();
Set<String> stringPermissionsList = new HashSet<>();
List<RoleVO> roleList = info.getRoleList();
for (RoleVO role : roleList) {
stringRolesList.add(role.getRole().getName());
List<Permission> permissionList = role.getPermissionList();
for (Permission permission : permissionList){
if (permission != null){
stringPermissionsList.add(permission.getName());
}
}
}
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
simpleAuthorizationInfo.addRoles(stringRolesList);
simpleAuthorizationInfo.addStringPermissions(stringPermissionsList);
return simpleAuthorizationInfo;
}
/**
* 登录时调用
*
* @param authenticationToken
* @return
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
//从token中获得用户信息,这个token是用户的输入信息
String username = (String) authenticationToken.getPrincipal();
UserVO info = userService.findUserInfoByUsername(username);
//取密码
String password = info.getPassword();
if (password == null || "".equals(password)) {
return null;
}
//设置盐值,使用账号作为盐值
ByteSource salt = ByteSource.Util.bytes(username);
//参数一传入对象,便于全局获取当前对象信息
return new SimpleAuthenticationInfo(info, password, salt, getName());
}
}
Realm能做的工作主要有以下几个方面:
- 验证是否能登录,并返回验证信息(getAuthenticationInfo方法)
- 验证是否有访问指定资源的权限,并返回所拥有的所有权限(getAuthorizationInfo方法)
- 判断是否支持token(例如:HostAuthenticationToken,UsernamePasswordToken等)(supports方法)
自定义Realm中实现的是前两个
3.5.2 自定义SessionManager:CustomSessionManager.java
将生成的sessionId作为认证时的token,登录之后的每次请求,header中必须携带token参数
public class CustomSessionManager extends DefaultWebSessionManager {
private static final String AUTHORIZATION = "token";
public CustomSessionManager() {
super();
}
@Override
protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
String sessionId = WebUtils.toHttp(request).getHeader(AUTHORIZATION);
if (sessionId != null) {
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE,
ShiroHttpServletRequest.COOKIE_SESSION_ID_SOURCE);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, sessionId);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
return sessionId;
} else {
return super.getSessionId(request, response);
}
}
}
3.5.3 自定义sessionId生成器:CustomSessionId.java
public class CustomSessionId implements SessionIdGenerator {
@Override
public Serializable generateId(Session session) {
return "kangaroohy" + UUID.randomUUID().toString().replace("-", "");
}
}
3.5.4 shiro配置文件:ShiroConfig.java
@Configuration
public class ShiroConfig {
@Value("${spring.redis.database}")
private Integer database;
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private Integer port;
@Value("${spring.redis.password}")
private String password;
@Bean(name = "shiroFilter")
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
//设置securityManager
shiroFilterFactoryBean.setSecurityManager(securityManager);
//需要登陆的接口,没有登陆就访问需要登陆的接口,则调用这个接口(非前后端分离,则调用登录界面)
shiroFilterFactoryBean.setLoginUrl("/pub/need_login");
//登录成功,跳转url,前后端分离的情况下,则没有这个接口的调用
shiroFilterFactoryBean.setSuccessUrl("/");
//已经登陆,但是访问的接口没有权限,类似403界面
shiroFilterFactoryBean.setUnauthorizedUrl("/pub/unauthorized");
//自定义退出后重定向的地址,前后端分离,用于返回退出成功信息
LogoutFilter logout = new LogoutFilter();
logout.setRedirectUrl("/pub/logout");
//设置自定义filter
Map<String, Filter> filterMap = new LinkedHashMap<>();
//自定义退出filter
filterMap.put("logout",logout);
shiroFilterFactoryBean.setFilters(filterMap);
//拦截器路径,务必设置为LinkedHashMap,否则部分路径拦截时有时无(不生效),因为如果使用HashMap,无序,而LinkedHashMap,有序
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
//退出过滤器,退出成功后,默认返回LoginUrl接口,即登录页,通过自定义,改到/pub/logout接口
filterChainDefinitionMap.put("/logout", "logout");
//匿名可访问
filterChainDefinitionMap.put("/pub/**", "anon");
//登录后可以访问
filterChainDefinitionMap.put("/user/**", "authc");
//通过认证才能访问
filterChainDefinitionMap.put("/**", "authc");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}
@Bean(name = "securityManager")
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
//如果不是前后端分离,不用设置这个
securityManager.setSessionManager(sessionManager());
//设置自定义的cacheManager
securityManager.setCacheManager(cacheManager());
//设置realm(推荐放到最后)
securityManager.setRealm(customRealm());
return securityManager;
}
/**
* 配置具体的cache实现类
*/
public org.crazycake.shiro.RedisCacheManager cacheManager(){
org.crazycake.shiro.RedisCacheManager redisCacheManager = new RedisCacheManager();
redisCacheManager.setRedisManager(redisManager());
//过期时间,单位 s
redisCacheManager.setExpire(Constant.CACHE_EXPIRE_TIME);
redisCacheManager.setPrincipalIdFieldName(Constant.ACCOUNT);
redisCacheManager.setKeyPrefix(Constant.ACTIVE_SHIRO_CACHE);
return redisCacheManager;
}
/**
* 自定义realm
* @return
*/
@Bean(name = "customRealm")
public CustomRealm customRealm() {
CustomRealm realm = new CustomRealm();
realm.setCredentialsMatcher(credentialsMatcher());
return realm;
}
/**
* 密码加解密规则设置
* @return
*/
@Bean(name = "credentialsMatcher")
public HashedCredentialsMatcher credentialsMatcher() {
HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher();
//设置散列算法,这里使用MD5
credentialsMatcher.setHashAlgorithmName(Constant.HASH_NAME);
//散列次数
credentialsMatcher.setHashIterations(Constant.HASH_TIME);
return credentialsMatcher;
}
@Bean(name = "sessionManager")
public SessionManager sessionManager(){
CustomSessionManager sessionManager = new CustomSessionManager();
//过期时间,默认30分钟超时,方法中单位是毫秒,此处15分钟过期:15 * 60 * 1000
sessionManager.setGlobalSessionTimeout(Constant.SESSION_EXPIRE_TIME);
//配置session持久化
sessionManager.setSessionDAO(redisSessionDAO());
sessionManager.setSessionIdCookieEnabled(false);
sessionManager.setSessionIdUrlRewritingEnabled(false);
sessionManager.setDeleteInvalidSessions(true);
return sessionManager;
}
/**
* 自定义Session持久化
* @return
*/
public RedisSessionDAO redisSessionDAO(){
RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
redisSessionDAO.setRedisManager(redisManager());
redisSessionDAO.setSessionIdGenerator(new CustomSessionId());
redisSessionDAO.setKeyPrefix(Constant.ACTIVE_SHIRO_SESSION);
return redisSessionDAO;
}
/**
* 配置redisManager
*/
public RedisManager redisManager(){
RedisManager redisManager = new RedisManager();
redisManager.setDatabase(database);
//在shiro-redis 3.2.3版本中,host合并了port
redisManager.setHost(host + ":" + port);
//redisManager.setPort(port);
redisManager.setPassword(password);
return redisManager;
}
/**
* 管理shiro一些Bean的生命周期,初始化和销毁
* @return
*/
@Bean(name = "lifecycleBeanPostProcessor")
public static LifecycleBeanPostProcessor lifecycleBeanPostProcessor(){
return new LifecycleBeanPostProcessor();
}
/**
* 使注解生效,不加这个,shrio的AOP注解不生效
* 如 Controller中 shiro的 @RequiresGust(游客可以访问) 注解
* @return
*/
@Bean(name = "authorizationAttributeSourceAdvisor")
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(){
AuthorizationAttributeSourceAdvisor sourceAdvisor = new AuthorizationAttributeSourceAdvisor();
sourceAdvisor.setSecurityManager(securityManager());
return sourceAdvisor;
}
/**
* 用来扫描上下文,寻找所有的Advisor(通知器),将符合条件的Advisor应用到切入点的Bean中
* 需要在LifecycleBeanPostProcessor创建之后才可以创建
* @return
*/
@Bean
@DependsOn("lifecycleBeanPostProcessor")
public static DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator(){
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
// 强制使用cglib,防止重复代理和可能引起代理出错的问题
// https://zhuanlan.zhihu.com/p/29161098
defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
return defaultAdvisorAutoProxyCreator;
}
}
3.5.5 登录用到的controller:PublicController.java
@RestController
@RequestMapping("/pub")
@Slf4j
public class PublicController {
UserService userService;
public PublicController(UserService userService) {
this.userService = userService;
}
@GetMapping("/need_login")
public RestResult<String> needLogin() {
return RestResult.error(RestCode.NEED_LOGIN_ERROR);
}
@GetMapping("/unauthorized")
public RestResult<String> unauthorized() {
return RestResult.error(RestCode.AUTHORIZATION_ERROR);
}
@PostMapping("/login")
public RestResult<UserVO> login(@RequestParam String username, @RequestParam String password, HttpServletResponse response) {
Subject subject = SecurityUtils.getSubject();
//踢出之前登录状态,同一账号 同一时间 只能在同一个地方登录
kickOutBefore(username.trim());
UserVO userInfo = userService.findUserInfoByUsername(username.trim());
//用户名查询用户
if (userInfo == null) {
return RestResult.error("账号不存在");
}
try {
UsernamePasswordToken userPwdToken = new UsernamePasswordToken(username.trim(), password.trim());
subject.login(userPwdToken);
String token = subject.getSession().getId().toString();
UserVO info = userService.findUserInfoByUsername(username.trim());
info.setPassword(null);
JedisUtil.setJson(Constant.ACTIVE_SHIRO_TOKEN + username.trim(), token, Constant.ONLINE_TOKEN_EXPIRE_TIME);
response.setHeader(Constant.AUTHORIZATION, token);
response.setHeader("Access-Control-Expose-Headers", Constant.AUTHORIZATION);
return RestResult.ok(info);
} catch (Exception e) {
e.printStackTrace();
return RestResult.error("用户名或密码错误");
}
}
/**
* 根据userId踢出 同一用户 不同浏览器登录session
* @param username 账号
*/
private void kickOutBefore(String username) {
String key = Constant.ACTIVE_SHIRO_TOKEN + username.trim();
if (JedisUtil.exists(key)) {
String value = JedisUtil.getJson(key);
if (JedisUtil.exists(Constant.ACTIVE_SHIRO_SESSION + value)) {
JedisUtil.delete(key, Constant.ACTIVE_SHIRO_SESSION + value);
} else {
JedisUtil.delete(key);
}
}
}
}
3.6 返回JSON数据的自定义类
- RestCode.java
public enum RestCode {
//运行时错误
UNKNOWN_ERROR(-1, "Unknown Error! Contact the administrator if this problem continues."),
//操作成功
SUCCESS(200, "SUCCESS"),
//请求参数错误、不合法
PARAM_ERROR(400, "Illegal Parameter!"),
//需要登录才能访问
NEED_LOGIN_ERROR(401, "Please login and visit again!"),
//没有访问权限,禁止访问
AUTHORIZATION_ERROR(403, "You are forbidden to view it!"),
//资源不存在
NOTFOUND_ERROR(404, "Not Found"),
//方法不支持
NOT_SUPPORT_ERROR(500, "Not support this method");
private Integer code;
private String message;
RestCode(){
}
RestCode(Integer code, String message) {
this.code = code;
this.message = message;
}
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}
- RestResult.java
@Data
public class RestResult<T> implements Serializable {
private static final long serialVersionUID = 7711799662216684129L;
@JSONField(ordinal = 1)
public int code;
@JSONField(ordinal = 2)
private String msg;
@JSONField(ordinal = 3)
private T data;
public RestResult() {
}
public RestResult(int code, String msg) {
this.code = code;
this.msg = msg;
}
public RestResult(int code, String msg, T data) {
this.code = code;
this.msg = msg;
this.data = data;
}
public static <T> RestResult<T> ok() {
return new RestResult<>(RestCode.SUCCESS.getCode(), RestCode.SUCCESS.getMessage());
}
public static <T> RestResult<T> ok(String msg) {
return new RestResult<>(RestCode.SUCCESS.getCode(), msg);
}
public static <T> RestResult<T> ok(T data) {
return new RestResult<>(RestCode.SUCCESS.getCode(), RestCode.SUCCESS.getMessage(), data);
}
public static <T> RestResult<T> error(String msg) {
return new RestResult<>(RestCode.UNKNOWN_ERROR.getCode(), msg);
}
public static <T> RestResult<T> error(RestCode restCode) {
return new RestResult<>(restCode.getCode(), restCode.getMessage());
}
public static <T> RestResult<T> error(RestCode restCode, String msg) {
return new RestResult<>(restCode.getCode(), msg);
}
public static <T> RestResult<T> error(RestCode restCode, String msg, T data) {
return new RestResult<>(restCode.getCode(), msg, data);
}
}
4 Demo下载
无Redis缓存版本:https://github.com/kangaroo1122/ssm_shiro
有Redis缓存版本:https://github.com/kangaroo1122/shiro-redis
两个版本都可以直接下载,按照给的数据库设计,创建数据库,修改数据库地址即可运行。
用户密码加密方式:可以查看自定义Realm,这样就能自己修改数据库用户数据了
其他
关于redis的配置文件和操作redis数据库的util,可以直接查看demo源码,Redis版本还集成了mybatis-plus枚举类型转换器,具体可以看mybatis-plus的官方介绍.