近一个月比较忙,公司接了一个新项目,领导要求用shiro进行安全管理,而且全公司只有我一个java,从项目搭建到具体接口全是一个人再弄。不过刚好前段时间大概学习了一下shiro的使用,还算顺利。
下面将项目中的shiro部分记录下来,为以后使用做一个备份。(因为是个人测试用的demo,好多地方再设计和实现的时候都是使用了最方便或者最简单的方法,希望不会误导各位看官)
*************************项目环境:springboot+jpa+hirbernate+mysql+shiro+maven*************************
前端很简陋,只是简单的用到了js,layui和vue,主要是为了方便展示数据
项目环境搭建和数据库点击这里查看,搭建完成后开始具体的权限管理工作
目录
1.首先在shiro的大管家ShiroConfig配置类中告诉大管家,注册的链接不需要拦截。
1.1先pom中添加jar包依赖(我用的Kaptcha,网上大部分都是用这个生成验证码)
1.2在springboot启动类中注入生成验证码的bean()
1.3 写一个token类,集成shiro提供的UsernamePasswordToken
项目结构
注册
1.首先在shiro的大管家ShiroConfig配置类中告诉大管家,注册的链接不需要拦截。
2.注册页面和实现
2.1注册页面
因为是测试demo我就只写一个简单的登录页面,只需要有一个form表单可以提交账号密码就行
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>注册</title>
</head>
<body>
<form action="/register" method="post">
用户名:<input type="text" name="username"/>
<br/>
密码:<input type="text" name="password"/>
<br/>
姓名:<input type="text" name="name"/>
<input type="submit" value="注册"/>
</form>
</body>
</html>
2.2注册实现
这一步主要就是把密码用md5加盐加密,将加密后的密码和盐值存入数据库,然后返回登录页面
(用户名重名校验啥的都没写,可以根据具体项目增加校验)
@RequestMapping("/register")
public String register(UserInfo user) {
String username=user.getUsername();
String password1=user.getPassword();
ByteSource salt = ByteSource.Util.bytes(username);
String password = new SimpleHash("MD5", password1,username+salt,1024).toString();
user.setSalt(salt.toString());
user.setPassword(password);
byte by=1;
user.setState(by);
dao.save(user);
return "login";
}
登录
1.验证码
1.1先pom中添加jar包依赖(我用的Kaptcha,网上大部分都是用这个生成验证码)
<!-- 验证码jar-->
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
1.2在springboot启动类中注入生成验证码的bean()
@Bean
public ServletRegistrationBean kaptchaServlet() {
ServletRegistrationBean registrationBean = new ServletRegistrationBean(new KaptchaServlet(), "/kaptcha.jpg");
registrationBean.addInitParameter(Constants.KAPTCHA_SESSION_CONFIG_KEY,
Constants.KAPTCHA_SESSION_KEY);
registrationBean.addInitParameter(Constants.KAPTCHA_IMAGE_HEIGHT, "60");//高度
registrationBean.addInitParameter(Constants.KAPTCHA_TEXTPRODUCER_FONT_SIZE, "50");//字体大小
registrationBean.addInitParameter(Constants.KAPTCHA_BORDER_THICKNESS, "1"); //边框
registrationBean.addInitParameter(Constants.KAPTCHA_TEXTPRODUCER_FONT_COLOR, "red"); //文字颜色
//可以设置很多属性,具体看com.google.code.kaptcha.Constants
// kaptcha.border 是否有边框 默认为true 我们可以自己设置yes,no
// kaptcha.border.color 边框颜色 默认为Color.BLACK
// kaptcha.border.thickness 边框粗细度 默认为1
// kaptcha.producer.impl 验证码生成器 默认为DefaultKaptcha
// kaptcha.textproducer.impl 验证码文本生成器 默认为DefaultTextCreator
// kaptcha.textproducer.char.string 验证码文本字符内容范围 默认为abcde2345678gfynmnpwx
// kaptcha.textproducer.char.length 验证码文本字符长度 默认为5
// kaptcha.textproducer.font.names 验证码文本字体样式 默认为new Font("Arial", 1, fontSize), new Font("Courier", 1, fontSize)
// kaptcha.textproducer.font.size 验证码文本字符大小 默认为40
// kaptcha.textproducer.font.color 验证码文本字符颜色 默认为Color.BLACK
// kaptcha.textproducer.char.space 验证码文本字符间距 默认为2
// kaptcha.noise.impl 验证码噪点生成对象 默认为DefaultNoise
// kaptcha.noise.color 验证码噪点颜色 默认为Color.BLACK
// kaptcha.obscurificator.impl 验证码样式引擎 默认为WaterRipple
// kaptcha.word.impl 验证码文本字符渲染 默认为DefaultWordRenderer
// kaptcha.background.impl 验证码背景生成器 默认为DefaultBackground
// kaptcha.background.clear.from 验证码背景颜色渐进 默认为Color.LIGHT_GRAY
// kaptcha.background.clear.to 验证码背景颜色渐进 默认为Color.WHITE
// kaptcha.image.width 验证码图片宽度 默认为200
// kaptcha.image.height 验证码图片高度 默认为50
return registrationBean;
}
1.3 写一个token类,集成shiro提供的UsernamePasswordToken
UsernamePasswordToken是shiro提供的用户令牌,我们扩展一下,用来接收用户输入的账号密码和验证码
package com.example.config;
import org.apache.shiro.authc.UsernamePasswordToken;
public class CaptchaUsernamePasswordToken extends UsernamePasswordToken {
private static final long serivalVersionUID = 1L;
//验证码字符串
private String captcha;
public CaptchaUsernamePasswordToken(String username, char[] password, boolean rememberMe, String host, String captcha) {
super(username,password,rememberMe, host);
this.captcha = captcha;
}
public static long getSerivalVersionUID() {
return serivalVersionUID;
}
public String getCaptcha() {
return captcha;
}
public void setCaptcha(String captcha) {
this.captcha = captcha;
}
}
1.4写一个获取验证码的拦截器
拦截到输入的验证码,与系统生成的验证码进行匹配,如果验证码错误则不再校验用户名和密码
package com.example.KaptchaFilter;
import com.example.Exception.IncorrectCaptchaException;
import com.example.config.CaptchaUsernamePasswordToken;
import com.google.code.kaptcha.Constants;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.filter.authc.FormAuthenticationFilter;
import org.apache.shiro.web.util.WebUtils;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
public class KaptchaFilter extends FormAuthenticationFilter {
public static final String DEFAULT_CAPTCHA_PARAM = "captcha";
private String captchaParam = DEFAULT_CAPTCHA_PARAM;
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
CaptchaUsernamePasswordToken token = createToken(request, response);
String username = token.getUsername();
try {
doCaptchaValidate((HttpServletRequest) request, token);
Subject subject = getSubject(request, response);
subject.login(token);
return onLoginSuccess(token, subject, request, response);
} catch (AuthenticationException e) {
return onLoginFailure(token,e,request,response);
}
}
//验证码校验
protected void doCaptchaValidate(HttpServletRequest request, CaptchaUsernamePasswordToken token) {
// 从session中获取图形吗字符串
String captcha = (String) request.getSession().getAttribute(Constants.KAPTCHA_SESSION_KEY);
// 校验
if (captcha == null || !captcha.equals(token.getCaptcha())) {
throw new IncorrectCaptchaException();
}
}
@Override
protected CaptchaUsernamePasswordToken createToken(ServletRequest request, ServletResponse response) {
String username = getUsername(request);
String password = getPassword(request);
String host = getHost(request);
boolean rememberMe = isRememberMe(request);
String captcha = getCaptcha(request);
return new CaptchaUsernamePasswordToken(username,password.toCharArray(),rememberMe,host,captcha);
}
protected String getCaptcha(ServletRequest request) {
return WebUtils.getCleanParam(request, getCaptchaParam());
}
//保存异常对象到request
@Override
protected void setFailureAttribute(ServletRequest request, org.apache.shiro.authc.AuthenticationException ae) {
request.setAttribute(getFailureKeyAttribute(), ae);
}
public String getCaptchaParam() {
return captchaParam;
}
public void setCaptchaParam(String captchaParam) {
this.captchaParam = captchaParam;
}
}
验证码校验的时候抛出了一个异常,在homecontroller中会根据登录时shiro校验完成后的异常,判断登录状态和错误原因,这个异常就是用来判断验证码是否输入正确的。
因此我们需要写一个异常提供给shiro使用
1.5验证码异常类
package com.example.Exception;
import org.apache.shiro.authc.AuthenticationException;
public class IncorrectCaptchaException extends AuthenticationException {
private static final long serivalVersionUID = 1L;
public IncorrectCaptchaException() {
super();
}
public IncorrectCaptchaException(String message, Throwable cause) {
super(message, cause);
}
public IncorrectCaptchaException(String message) {
super(message);
}
public IncorrectCaptchaException(Throwable cause) {
super(cause);
}
}
1.6将验证码拦截器配置给shiro
1.7登录controller中增加验证码异常判断
@RequestMapping("/login")
public String login(HttpServletRequest request, Map<String, Object> map) throws Exception{
Object ob=SecurityUtils.getSubject().getPrincipal();
if(ob!=null){
return "index";
}
System.out.println("HomeController.login()");
// 登录失败从request中获取shiro处理的异常信息。
// shiroLoginFailure:就是shiro异常类的全类名.
Object exception = request.getAttribute("shiroLoginFailure");
System.out.println("exception=" + exception);
System.out.println(IncorrectCredentialsException.class.getName());
String msg = "";
if (exception != null) {
if (UnknownAccountException.class.isInstance(exception)) {
msg = "UnknownAccountException -- > 账号不存在:";
} else if (IncorrectCredentialsException.class.isInstance(exception)) {
msg = "IncorrectCredentialsException -- > 密码不正确:";
} else if (IncorrectCaptchaException.class.isInstance(exception)) {
msg = "kaptchaValidateFailed -- > 验证码错误";
} else {
msg = "else >> "+exception;
}
}
map.put("msg", msg);
// 此方法不处理登录成功,由shiro进行处理
return "login";
}
1.8 登录页面放入验证码图片和验证码输入框
<div class="layui-inline" style="width: 85%">
<label class="layui-form-label">验证码</label>
<div class="layui-inline">
<input type="text" id="captcha" name="captcha" lay-verify="required" placeholder="请输入验证码" autocomplete="off" class="layui-input"/>
</div>
<div class="layui-inline"><img src="kaptcha.jpg" id="kaptchaImage" /></div>
</div>
2.密码校验
这里需要做的只是将根据用户输入的用户名查询出来的userinfo扔给shiro专门用来校验密码的方法doGetAuthenticationInfo,让shiro自己去验证就行,关于如何修改shiro的验证方法,比如修改解密算法什么的,在我上一贴中有介绍,这里就不多说了
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token)
throws AuthenticationException {
System.out.println("MyShiroRealm.doGetAuthenticationInfo()");
//获取用户的输入的账号.
String username = (String)token.getPrincipal();
String password = new String((char[])token.getCredentials()); //得到密码
//通过username从数据库中查找 User对象,如果找到,没找到.
//实际项目中,这里可以根据实际情况做缓存,如果不做,Shiro自己也是有时间间隔机制,2分钟内不会重复执行该方法
UserInfo userInfo = userInfoService.findByUsername(username);
System.out.println("----->>userInfo="+userInfo);
System.out.print(userInfo.getPassword());
if(userInfo == null){
return null;
}
/*
* 获取权限信息:这里没有进行实现,
* 请自行根据UserInfo,Role,Permission进行实现;
* 获取之后可以在前端for循环显示所有链接;
*/
//userInfo.setPermissions(userService.findPermissions(user));
System.out.println(userInfo.getCredentialsSalt());
System.out.println(ByteSource.Util.bytes(userInfo.getCredentialsSalt()));
SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(
userInfo, //用户名
userInfo.getPassword(), //密码
ByteSource.Util.bytes(userInfo.getCredentialsSalt()),//salt=username+salt
getName() //realm name
);
return authenticationInfo;
}
3.权限校验
这里个人理解是shiro提供了一个用来放用户授权信息的对象SimpleAuthorizationInfo和一个用来放身份信息的集合PrincipalCollection。我们从PrincipalCollection中获取当前登录的用户对象,因为用户对象、角色对象、资源对象三者存在关联关系,在设计数据库时已经设计好了关联关系,并且三个对象已经用jpa进行了关了,可以直接从其中一个对象中获取关联的其他对象。而shiro的权限管理不需要我们操作。
所以这里的权限管理只需要从PrincipalCollection中获取用户对象,再用户对象中获取到角色对象,再从角色对象中获取资源对象,最后将这些对象放入到SimpleAuthorizationInfo中,让shiro自己去给我们判断用户是否有访问权限就可以了
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
System.out.println("权限配置-->MyShiroRealm.doGetAuthorizationInfo()");
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
UserInfo userInfo = (UserInfo) principals.getPrimaryPrincipal();
// BeanUtils.copyProperties(oo,userInfo);
for(SysRole role:userInfo.getRoleList()){
authorizationInfo.addRole(role.getRole());
for(SysPermission p:role.getPermissions()){
authorizationInfo.addStringPermission(p.getPermission());
}
}
return authorizationInfo;
}
这里有一点需要注意,在
UserInfo userInfo = (UserInfo) principals.getPrimaryPrincipal();
的时候可能会,报com.example.entity.UserInfo cannot be cast to com.example.entity.UserInfo,
在网上找到的办法是将springboot的热部署关掉就ok了,但是原理不知道,希望有大佬解答
设置资源,角色
1.开启shiro的资源监控
我们将需要保护的资源或者链接纳入shiro控制的方位内,具体做法就是在controller方法前加上@RequiresPermissions("view")注解,括号里为资源名称,与数据库资源表中的字段对应。资源名称支持通配符,可以写成
@RequiresPermissions("userInfo:view") 表示查看用户
@RequiresPermissions("userInfo:view,add")表示查看和增加用户
@RequiresPermissions("userInfo:view:123")表示只能查看id为123的用户 这一点没有测试,有时间了再测试一下具体用法
@RequestMapping("/userManager")
@RequiresPermissions("userInfo:manager")//权限管理;
public String userManager(){
return "userInfoManager";
}
为了能让系统识别@RequiresPermissions注解,需要在shiroconfig配置中开启改注解
@Bean
@DependsOn("lifecycleBeanPostProcessor")
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator(){
DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator();
creator.setProxyTargetClass(true);
return creator;
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager){
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor(){
return new LifecycleBeanPostProcessor();
}
2.设置用户角色、权限
2.1用户维护
用户查看界面
增加一个简单的用户查询界面,用来获取全部用户,并可以查看和分配用户角色
注:之后的页面代码都是套用的这个页面,就不贴出来了
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8"/>
<title>UserInfo</title>
<script type="text/javascript" th:src="@{../static/source/js/layui/layui.js}"></script>
<script type="text/javascript" th:src="@{../static/source/js/layui/layui.all.js}"></script>
<script type="text/javascript" th:src="@{../static/vue.min.js}"></script>
<script type="text/javascript" th:src="@{../static/jquery-3.3.1.min.js}"></script>
<link rel="stylesheet" th:href="@{../static/source/js/layui/css/layui.css}"/>
</head>
<body>
<h3>用户查询界面</h3>
<div id="userinfo">
<table class="layui-table">
<thead>
<tr>
<th>用户名1111</th>
<th>姓名</th>
<th>id</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="site in tablePerson">
<th>{{site.name}}</th>
<th>{{site.username}}</th>
<th>{{site.uid}}</th>
<th><span @click="changeRole(site.uid)" >查看</span>
<span @click="allotRole(site.uid)" >分配角色</span>
</th>
</tr>
</tbody>
</table>
</div>
</body>
<script>
var userinfo = new Vue({
el: '#userinfo',
data: {
tablePerson: []
},
created: function () {
//为了在内部函数能使用外部函数的this对象,要给它赋值了一个名叫self的变量。
var self = this;
var codeurl = "/userInfo/queryUser";
$.ajax({
type: 'get',
url: codeurl,
async: false,
contentType: 'application/json;charset=UTF-8',
}).then(function (res) {
console.log(res.data);
console.log("###res"+JSON.stringify(res));
//把从json获取的数据赋值给数组
self.tablePerson = res;
console.log(self);
}).fail(function () {
console.log('失败');
})
},
});
function changeRole(id) {
var url="userManager";
layer.open({
type: 2,
skin: 'layui-layer-lan',
title: '角色管理',
fix: false,
shadeClose: false,
maxmin: true,
id:'selectUser',
move: false,
closeBtn:2,
//以下代码为打开窗口添加按钮
/* btn: ['确定', '取消'],
btnAlign: 'c',
yes: function(index, layero){
/* //layer.closeAll();//关闭所有弹出层
//var parentWin = layero.find('iframe')[0];
var parentWin = layer.getChildFrame('body', index);
alert(parentWin);
parentWin.contentWindow.doOk();
//layer.close(index);//这块是点击确定关闭这个弹出层
}, */
area: ['750px', '450px'],
content: url,
success: function (layero, index) {
// 获取子页面的iframe
var iframe = window['layui-layer-iframe' + index];
// 向子页面的全局函数child传参
iframe.child(id);
},
end: function () {
document.getElementById('groupName').value = "";
}
});
// console.log(id);
// var codeurl = "/userInfo/userManager";
// var data={"userid":id};
// $.ajax({
// type: 'get',
// url: codeurl,
// data:data,
// async: false,
// contentType: 'application/json;charset=UTF-8',
// }).then(function (res) {
// console.log(JSON.stringify(res.data));
// location.reload();
//
// }).fail(function () {
// console.log('失败');
// })
}
function allotRole(uid){
var url="allotRole";
layer.open({
type: 2,
skin: 'layui-layer-lan',
title: '角色分配',
fix: false,
shadeClose: false,
maxmin: true,
id:'selectUser',
move: false,
closeBtn:2,
//以下代码为打开窗口添加按钮
/* btn: ['确定', '取消'],
btnAlign: 'c',
yes: function(index, layero){
/* //layer.closeAll();//关闭所有弹出层
//var parentWin = layero.find('iframe')[0];
var parentWin = layer.getChildFrame('body', index);
alert(parentWin);
parentWin.contentWindow.doOk();
//layer.close(index);//这块是点击确定关闭这个弹出层
}, */
area: ['750px', '450px'],
content: url,
success: function (layero, index) {
// 获取子页面的iframe
var iframe = window['layui-layer-iframe' + index];
// 向子页面的全局函数child传参
iframe.child(uid);
},
end: function () {
document.getElementById('groupName').value = "";
}
});
}
</script>
</html>
这里有一点需要注意的,程序知行到这里的时候,一直报java.lang.IllegalStateException: Cannot call sendError() after the response has been committed这个错误,后来咨询过大佬之后,大概明白了。
因为我的user实体中关联着role实体,而role又关联着user实体,所以在序列化实体的时候就会进入到死循环的状态,
解决办法就是在实体的get,set方法前加上@JsonBackReference注解。告诉程序不要级联查询,不过这样做的问题就是,在序列化user实体的时候,不会返回role的值。
用户管理controller
package com.example.controller;
import com.example.dao.UserInfoDao;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import java.util.List;
@Controller
@RequestMapping("/userInfo")
public class UserInfoController {
@Autowired
UserInfoDao userInfoDao;
/**
* 用户查询.
* @return
*/
@RequestMapping("/userList")
@RequiresPermissions("userInfo:view")//权限管理;
public String userInfo(){
return "userInfo";
}
/**
* 用户添加;
* @return
*/
@RequestMapping("/userAdd")
@RequiresPermissions("userInfo:add")//权限管理;
public String userInfoAdd(){
return "userInfoAdd";
}
/**
* 用户删除;
* @return
*/
@RequestMapping("/userDel")
@RequiresPermissions("userInfo:del")//权限管理;
public String userDel(){
return "userInfoDel";
}
@RequestMapping("/userManager")
@RequiresPermissions("userInfo:manager")//权限管理;
public String userManager(){
return "userInfoManager";
}
/**
* 获取全部用户信息
* @return
*/
@RequestMapping(value="queryUser", method= RequestMethod.GET)
// @RequiresPermissions("userInfo:queryUser")//权限管理;
@ResponseBody
public List queryUser(){
List list= userInfoDao.findAllUser();
return list;
}
@RequestMapping(value="querypermission", method= RequestMethod.GET)
// @RequiresPermissions("userInfo:queryUser")//权限管理;
public String queryPermission(){
return "permissionman";
}
@RequestMapping(value="allotRole", method= RequestMethod.GET)
// @RequiresPermissions("userInfo:queryUser")//权限管理;
public String allotRole(){
return "allotRole";
}
}
2.2用户角色资源管理界面
添加用户、删除用户、取消授权等操作在demo里没有写,步骤就是增加用户与角色对照表实体,角色与资源对照表实体,在取消授权或增加授权的时候,操作对照表实体,在数据库中增加对应数据就可以了。
实现的时候将增加资源和角色的权限控制起来,设置只有管理员或上级角色才可以增加删除
下面是权限控制的例子,可以通过类似的方法控制增加和删除的权限
@RequestMapping("/userManager")
@RequiresPermissions("userInfo:manager")//只有用户具有userInfo:manager 权限的时候才可以访问
//可以修改为role:xxx 控制用户是否能操作角色,
//或xxx:xxx 控制对应的资源
public String userManager(){
return "userInfoManager";
}