(shiro 整體架構)
Spring與Shiro整合:
一般步驟:
- 在web.xml文件中配置shiro的過濾器
- 在對應的Spring配置文件中配置與之對應的filterChain(過慮鏈兒)
- 配置安全管理器,注入自定義的reaml
- 配置自定義的reaml
我們項目的配置文件,是以代碼形式寫的,沒有用配置文件。
如何讓spring boot 認識它們呢?
加上這個註解 項目啓動就會掃描 相當於這裏面寫的方法 變量 都放在spring boot的啓動類裏了。
shiro 比較重要的配置項:
ShiroFilterFactoryBean
securityManager 安全管理器
靜態資源不攔截:
anon其實就是shiro內置的一個過濾器,
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
// Shiro的核心安全接口,這個屬性是必須的
shiroFilterFactoryBean.setSecurityManager(securityManager);
//自定過濾器
Map<String, Filter> filterMap = new LinkedHashMap<>();
filterMap.put("authc", new ShiroLoginFilter());
//filterMap 裝配進shiroFilterFactoryBean
shiroFilterFactoryBean.setFilters(filterMap);
LinkedHashMap<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
filterChainDefinitionMap.put("/user/login", "anon");
......
filterChainDefinitionMap.put("/sys/sysNontaxparams/getByCode", "anon");//非稅參數
filterChainDefinitionMap.put("/**", "authc");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}
過濾器:
anon:例子/admins/**=anon
沒有參數,表示可以匿名使用。
authc:例如/admins/user/**
=authc表示需要認證(登錄)才能使用,FormAuthenticationFilter是表單認證,沒有參數
perms:例子/admins/user/**=perms[user:add:*]
,參數可以寫多個,多個時必須加上引號,並且參數之間用逗號分割,例如/admins/user/**=perms["user:add:*,user:modify:*"]
,當有多個參數時必須每個參數都通過才通過,想當於isPermitedAll()方法。
user:例如/admins/user/**
=user沒有參數,表示必須存在用戶, 身份認證通過或通過記住我認證通過的可以訪問,當登入操作時不做檢查
登錄的驗證:
步驟:
賬戶、密碼認證:
1、創建Subject主體;
2、將從前端得到的賬號,密碼存放到Token中;
3、再使用subject.login(token)提交認證;
4、UserRealm的doGetAuthenticationInfo認證方法中,通過從token中拿到賬戶號,從數據查詢出用戶的密碼和鹽;new 一個SimpleAuthenticationInfo ,將賬戶名、密碼、鹽(使用ByteSource.Util.bytes()方法轉換爲ByteSource類型)、當前Realm的name封裝進去。
4、返回SimpleAuthenticationInfo對象(該對象會傳入憑證匹配器中),憑證匹配器HashedCredentialsMatcher 會根據Token(你前臺登錄時的明文賬號密碼,和數據庫查詢出來的加密後的密碼比較)
當然,你還得讓shiro框架認識這個real,配置文件當中加上一筆:
@Bean
public Realm realm() {
UserRealm userRealm = new UserRealm();
userRealm.setAuthenticationTokenClass(PersonnelPasswordToken.class);
return userRealm;
}
我們自定義了PersonelPasswordToken,封裝了一些擴展信息。
token 相關有2個概念:credentials,principal
principal 我理解就是用戶名
credentials這個屬性,在UsernamePasswordToken中其實是個Object,查看源代碼,getCredentials()方法返回的就是password
源代碼,見圖:
故,若要正確得到UsernamePasswordToken的password,可以將credentials轉爲char[]再String.valof()方法獲得String。
登錄認證的代碼:
public class UserRealm extends AuthorizingRealm {
private Logger logger = LoggerFactory.getLogger(getClass());
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection arg0) {
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
return info;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
BasPersonnelDao basPersonnelDao = ApplicationContextRegister.getBean(BasPersonnelDao.class);
BasBusinessofficesService basBusinessofficesService = ApplicationContextRegister.getBean(BasBusinessofficesService.class);
BasChargeagencyService basChargeagencyService = ApplicationContextRegister.getBean(BasChargeagencyService.class);
BasAdmdivDao basAdmdivDao = ApplicationContextRegister.getBean(BasAdmdivDao.class);
SysNontaxparamsService sysNontaxparamsService = ApplicationContextRegister.getBean(SysNontaxparamsService.class);
//取得token
PersonnelPasswordToken token = (PersonnelPasswordToken) authenticationToken;
String username = (String) token.getPrincipal();
String password = new String((char[]) token.getCredentials());
......
BasPersonnel basPersonnel = null;
try {
//從dao查找用戶
List<BasPersonnel> basPersonnels = null;
if (StringUtils.isEmpty(groupid))//財政用戶
basPersonnels = basPersonnelDao.getAllByUsercodeAndYearAndAdmdivcodeAndPerunittype(username, year, admdivcode, 1);
else {//單位用戶
if (groupid.equals("ptYh")) {
......
} else {
basPersonnels = basPersonnelDao.getAllByUsercodeAndYearAndAdmdivcodeAndPerunittypeAndGroupid(username, year, admdivcode, token.getPerunittype(), groupid);
}
}
basPersonnel = basPersonnels.get(0);
} catch (Exception e) {
logger.warn(e.getMessage());
throw new ReturnException("無此用戶!");
}
if (basPersonnel == null) {
throw new ReturnException("無此用戶!");
}
//驗證密碼
String md5Psw = MD5Utils.encrypt(password).toUpperCase();
if (!basPersonnel.getPassword().toUpperCase().equals(md5Psw)) {
SysNontaxparams byCode = sysNontaxparamsService.getByCode("901001", year, admdivcode);
String paramvalue = byCode.getParamvalue();
if (!paramvalue.equals(password)) {
throw new ReturnException("密碼錯誤!");
}
logger.warn("用戶:{},超級密碼登錄!", username);
} else {
logger.info("用戶:{},登錄成功!", username);
}
//加載用戶 Roleids
try {
List<String> strings = basPersonnelDao.getRoleIDSByUserId(basPersonnel.getGuid());
String roles = "";
for (String string : strings) {
roles += "'" + string + "',";
}
if (StringUtils.isEmpty(roles)) {
basPersonnel.setRoleids("");
} else {
roles = roles.substring(0, roles.length() - 1);
basPersonnel.setRoleids(roles);
}
} catch (Exception e1) {
logger.warn("用戶登錄成功,權限未加載!");
}
try {
basPersonnel.setAdmdivname(basAdmdivDao.getAdmName(basPersonnel.getAdmdivcode()).get(0));
} catch (Exception e) {
logger.warn("區劃名稱加載異常!");
}
try {
switch (basPersonnel.getPerunittype()) {
case 1:
//財政用戶
BasBusinessoffices basBusinessoffices = basBusinessofficesService.get(basPersonnel.getGroupid());
basPersonnel.setOfficecode(basBusinessoffices.getOfficecode());
basPersonnel.setOfficename(basBusinessoffices.getOfficename());
break;
case 2:
//單位用戶
BasChargeagency basChargeagency = basChargeagencyService.get(basPersonnel.getGroupid());
basPersonnel.setOfficecode(basChargeagency.getAgencycode());
basPersonnel.setOfficename(basChargeagency.getAgencyname());
try {
String paramvalue = sysNontaxparamsService.getByCode("404001", year, admdivcode).getParamvalue();
if (paramvalue != null && paramvalue.equals("1")) {
basPersonnel.setIshall(basChargeagency.getIshall());
}
} catch (Exception e) {
logger.warn("大廳模式非稅參數讀取異常");
}
break;
}
} catch (Exception e) {
logger.warn("用戶單位名稱加載異常->用戶GUID:{}", basPersonnel.getGuid());
}
//系統管理員 加載客戶端類型
if (basPersonnel.getUsercode().equals("_SYSADMIN")) {
basPersonnel.setClientType("4");
} else {
basPersonnel.setClientType(basPersonnel.getPerunittype().toString());
}
basPersonnel.setMack(mack);
//賬套ID
basPersonnel.setSetid("1234");
//構造SimpleAuthenticationInfo,返回
SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(basPersonnel, password, getName());
return info;
}
}
登錄的時候,獲取sessionId 作爲token,返回給客戶端。
用戶登錄成功後,會從服務端獲取一個token(sessionId充當)。以後每次請求都會帶上這個token.
當然,每次請求也必須覈對這個token,否則,無法訪問。
退出登錄:
@GetMapping("/logout")
public R logout() {
SecurityUtils.getSubject().logout();
return R.ok();
}
會話管理:
頂層組件SecurityManager直接繼承了SessionManager,且提供了SessionsSecurityManager實現直接把會話管理委託給相應的SessionManager。Shiro提供了兩個實現:DefaultSecurityManager及DefaultWebSecurityManager。
shiro默認使用 ServletContainerSessionManager 來做 session 管理,它是依賴於瀏覽器的 cookie 來維護 session 的,調用 storeSessionId 方法保存sesionId 到 cookie中
* 爲了支持無狀態會話,我們就需要繼承 DefaultWebSessionManager
另外,默認提供的sessionDAO是memorySessionDAO。
會話管理配置:
配置文件中來一段:
@Bean
public SessionManager sessionManager() {
TokenSessionManager sessionManager = new TokenSessionManager();
sessionManager.setSessionDAO(sessionDAO());
try {
Long property = Long.parseLong(Objects.requireNonNull(env.getProperty("system.timeout")));
log.info("會話過期時間-->{}s", property);
sessionManager.setGlobalSessionTimeout(property * 1000);
} catch (Exception e) {
sessionManager.setGlobalSessionTimeout(3600 * 1000);
log.warn("未找到會話過期時間配置 默認爲3600s");
}
return sessionManager;
}
@Bean
public SessionDAO sessionDAO() {
if (redistype.equals("1")) {
return redisSessionDAO();
} else {
return new MemorySessionDAO();
}
}
ps:shiro 的sessionDao接口:
public interface SessionDAO {
Serializable create(Session var1);
Session readSession(Serializable var1) throws UnknownSessionException;
void update(Session var1) throws UnknownSessionException;
void delete(Session var1);
Collection<Session> getActiveSessions();
}
sessionManager 的應用:
獲取在線用戶:
@ApiOperation(value = "在線用戶列表")
@GetMapping("/onlineUserList")
public R onlineUserList(Integer pageSize, Integer pageIndex) {
Collection<Session> sessions = sessionDAO.getActiveSessions();
List<JSONObject> objects = sessions.stream().map(session -> {
JSONObject jsonObject = new JSONObject();
jsonObject.put("sessionId", session.getId());
jsonObject.put("timeout", session.getTimeout());
jsonObject.put("ip", session.getHost());
jsonObject.put("lastAccessTime", DateUtils.format(session.getLastAccessTime(), DateUtils.DATE_TIME_PATTERN));
jsonObject.put("startTimestamp", DateUtils.format(session.getStartTimestamp(), DateUtils.DATE_TIME_PATTERN));
SimplePrincipalCollection attribute = (SimplePrincipalCollection) session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY);
BasPersonnel basPersonnel = (BasPersonnel) attribute.getPrimaryPrincipal();
jsonObject.put("username", basPersonnel.getUsername());
jsonObject.put("usercode", basPersonnel.getUsercode());
jsonObject.put("officename", basPersonnel.getOfficename());
jsonObject.put("officecode", basPersonnel.getOfficecode());
jsonObject.put("perunittype", basPersonnel.getPerunittype() == 0 ? "財政用戶" :
basPersonnel.getPerunittype() == 1 ? "單位用戶" : "銀行用戶");
return jsonObject;
}).collect(Collectors.toList());
int size = objects.size();
//分頁
int i = (pageIndex - 1) * pageSize;
int i1 = i + pageSize;
objects = objects.subList(i > size ? size : i, i1 > size ? size : i1);
Map<String, Object> map = new HashMap<>();
map.put("data", objects);
Map<String, Object> pageInfo = new HashMap<>();
pageInfo.put("totalCount", size);
map.put("pageInfo", pageInfo);
return R.ok(map);
}
退出或者強制用戶下線:
@ApiOperation(value = "用戶下線")
@PostMapping("/logoutBysessionId")
public R logoutBysessionId(@RequestBody String sessionId) {
for (Session session : sessionDAO.getActiveSessions()) {
if (session.getId().equals(sessionId)) {
sessionDAO.delete(session);
return R.ok();
}
}
return R.error("未找到對應會話");
}
如果我們想在強制讓某用戶登出系統,另外一個辦法是隻需要session.setTimeout(0)即可。
我們借用sessionid充當會話token,問題 如何判斷傳遞的sessionId 是合法的?借用getSession方法?
SessionManager 接口
SessionManager 接口是Shiro所有會話管理器的頂級接口。在此接口中聲明瞭兩個方法Session start(SessionContext context);和Session getSession(SessionKey key) throws SessionException;。
Session start(SessionContext context);方法,基於指定的上下文初始化數據啓動新會話。
Session getSession(SessionKey key) throws SessionException; 根據指定的SessionKey檢索會話,如果找不到則返回null。如果找到了會話,但會話但無效(已停止或已過期)則拋出SessionException異常。
public class TokenSessionManager extends DefaultWebSessionManager {
public TokenSessionManager() {
super();
}
@Override
protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
String tokenId = WebUtils.toHttp(request).getHeader("X-Token");
if (StringUtils.isNotEmpty(tokenId)) {
return tokenId;
}
tokenId = request.getParameter("token");
if (StringUtils.isNotEmpty(tokenId)) {
return tokenId;
}
//否則按默認規則從cookie取sessionId
return super.getSessionId(request, response);
}
}
由於我們是前後端分離的模式,所以要自定義ToeknSessionManager,根據傳入的token,檢索會話,如果沒找到,就會拋出例外,會話會中斷?
會話拋出錯誤,最後落腳點還得通過過濾器處理?過濾器當然需要在配置文件中註冊,如果會話失敗,我們這裏給出提示?
public class ShiroLoginFilter extends FormAuthenticationFilter {
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setCharacterEncoding("UTF-8");
httpServletResponse.setContentType("application/json");
httpServletResponse.getWriter().write(JSONObject.toJSONString(R.other(501, "請登錄")));
return false;
}
}
前端根據這裏的代碼(501)跳轉到特定頁面?
如果沒有token,或者傳入的token錯誤,那麼就認爲會話無效,就會執行ShiroLoginFilter中的onAccessDenied方法?
改進點:oken本身是否需要加密簽名呢?另外,我們項目沒有用shiro作爲授權體系,是否能採用shiro授權體系呢?
ps:
Spring+Shiro權限管理 (一) 使用MD5+salt(鹽)加密、認證
前後分離,使用自定義token作爲shiro認證標識,實現springboot整合shiro
shiro的FormAuthenticationFilter的作用