成長記錄貼之springboot+shiro(二) {完成一個完整的許可權控制,詳細步驟}
近一個月比較忙,公司接了一個新專案,領導要求用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";
}
2.3介面