Shiro 第二十三章 多專案集中許可權管理及分散式會話
在做一些企業內部專案時或一些網際網路後臺時;可能會涉及到集中許可權管理,統一進行多專案的許可權管理;另外也需要統一的會話管理,即實現單點身份認證和授權控制。
學習本章之前,請務必先學習《第十章 會話管理》和《第十六章 綜合例項》,本章程式碼都是基於這兩章的程式碼基礎上完成的。
本章示例是同域名的場景下完成的,如果跨域請參考《第十五章 單點登入》和《第十七章 OAuth2整合》瞭解使用CAS或OAuth2實現跨域的身份驗證和授權。另外比如客戶端/伺服器端的安全校驗可參考《第二十章 無狀態Web應用整合》。
部署架構
1、有三個應用:用於使用者/許可權控制的Server(埠:8080);兩個應用App1(埠9080)和App2(埠10080);
2、使用Nginx反向代理這三個應用,nginx.conf的server配置部分如下:
Java程式碼- server {
- listen 80;
- server_name localhost;
- charset utf-8;
- location ~ ^/(chapter23-server)/ {
- proxy_pass http://127.0.0.1:8080;
- index /;
- proxy_set_header Host $host;
- }
- location ~ ^/(chapter23-app1)/ {
- proxy_pass http://127.0.0.1:9080;
- index /;
- proxy_set_header Host $host;
- }
- location ~ ^/(chapter23-app2)/ {
- proxy_pass http://127.0.0.1:10080;
- index /;
- proxy_set_header Host $host;
- }
- }
Nginx的安裝及使用請自行搜尋學習,本文不再闡述。
專案架構
1、首先通過使用者/許可權Server維護使用者、應用、許可權資訊;資料都持久化到MySQL資料庫中;
2、應用App1/應用App2使用客戶端Client遠端呼叫使用者/許可權Server獲取會話及許可權資訊。
此處使用Mysql儲存會話,而不是使用如Memcached/Redis之類的,主要目的是降低學習成本;如果換成如Redis也不會很難;如:
使用如Redis還一個好處就是無需在使用者/許可權Server中開會話過期排程器,可以藉助Redis自身的過期策略來完成。
模組關係依賴
1、shiro-example-chapter23-pom模組:提供了其他所有模組的依賴;這樣其他模組直接繼承它即可,簡化依賴配置,如shiro-example-chapter23-server:
Java程式碼- <parent>
- <artifactId>shiro-example-chapter23-pom</artifactId>
- <groupId>com.github.zhangkaitao</groupId>
- <version>1.0-SNAPSHOT</version>
- </parent>
2、shiro-example-chapter23-core模組:提供給shiro-example-chapter23-server、shiro-example-chapter23-client、shiro-example-chapter23-app*模組的核心依賴,比如遠端呼叫介面等;
3、shiro-example-chapter23-server模組:提供了使用者、應用、許可權管理功能;
4、shiro-example-chapter23-client模組:提供給應用模組獲取會話及應用對應的許可權資訊;
5、shiro-example-chapter23-app*模組:各個子應用,如一些內部管理系統應用;其登入都跳到shiro-example-chapter23-server登入;另外許可權都從shiro-example-chapter23-server獲取(如通過遠端呼叫)。
shiro-example-chapter23-pom模組
其pom.xml的packaging型別為pom,並且在該pom中加入其他模組需要的依賴,然後其他模組只需要把該模組設定為parent即可自動繼承這些依賴,如shiro-example-chapter23-server模組:
Java程式碼- <parent>
- <artifactId>shiro-example-chapter23-pom</artifactId>
- <groupId>com.github.zhangkaitao</groupId>
- <version>1.0-SNAPSHOT</version>
- </parent>
簡化其他模組的依賴配置等。
shiro-example-chapter23-core模組
提供了其他模組共有的依賴,如遠端呼叫介面:
Java程式碼- public interface RemoteServiceInterface {
- public Session getSession(String appKey, Serializable sessionId);
- Serializable createSession(Session session);
- public void updateSession(String appKey, Session session);
- public void deleteSession(String appKey, Session session);
- public PermissionContext getPermissions(String appKey, String username);
- }
提供了會話的CRUD,及根據應用key和使用者名稱獲取許可權上下文(包括角色和許可權字串);shiro-example-chapter23-server模組服務端實現;shiro-example-chapter23-client模組客戶端呼叫。
另外提供了com.github.zhangkaitao.shiro.chapter23.core.ClientSavedRequest,其擴充套件了org.apache.shiro.web.util.SavedRequest;用於shiro-example-chapter23-app*模組當訪問一些需要登入的請求時,自動把請求儲存下來,然後重定向到shiro-example-chapter23-server模組登入;登入成功後再重定向回來;因為SavedRequest不儲存URL中的schema://domain:port部分;所以才需要擴充套件SavedRequest;使得ClientSavedRequest能儲存schema://domain:port;這樣才能從一個應用重定向另一個(要不然只能在一個應用內重定向):
Java程式碼- public String getRequestUrl() {
- String requestURI = getRequestURI();
- if(backUrl != null) {//1
- if(backUrl.toLowerCase().startsWith("http://") || backUrl.toLowerCase().startsWith("https://")) {
- return backUrl;
- } else if(!backUrl.startsWith(contextPath)) {//2
- requestURI = contextPath + backUrl;
- } else {//3
- requestURI = backUrl;
- }
- }
- StringBuilder requestUrl = new StringBuilder(scheme);//4
- requestUrl.append("://");
- requestUrl.append(domain);//5
- //6
- if("http".equalsIgnoreCase(scheme) && port != 80) {
- requestUrl.append(":").append(String.valueOf(port));
- } else if("https".equalsIgnoreCase(scheme) && port != 443) {
- requestUrl.append(":").append(String.valueOf(port));
- }
- //7
- requestUrl.append(requestURI);
- //8
- if (backUrl == null && getQueryString() != null) {
- requestUrl.append("?").append(getQueryString());
- }
- return requestUrl.toString();
- }
1、如果從外部傳入了successUrl(登入成功之後重定向的地址),且以http://或https://開頭那麼直接返回(相應的攔截器直接重定向到它即可);
2、如果successUrl有值但沒有上下文,拼上上下文;
3、否則,如果successUrl有值,直接賦值給requestUrl即可;否則,如果successUrl沒值,那麼requestUrl就是當前請求的地址;
5、拼上url前邊的schema,如http或https;
6、拼上域名;
7、拼上重定向到的地址(帶上下文);
8、如果successUrl沒值,且有查詢引數,拼上;
9返回該地址,相應的攔截器直接重定向到它即可。
shiro-example-chapter23-server模組
簡單的實體關係圖
簡單資料字典
使用者(sys_user)
名稱 | 型別 | 長度 | 描述 |
id | bigint | 編號 主鍵 | |
username | varchar | 100 | 使用者名稱 |
password | varchar | 100 | 密碼 |
salt | varchar | 50 | 鹽 |
locked | bool | 賬戶是否鎖定 |
應用(sys_app)
名稱 | 型別 | 長度 | 描述 |
id | bigint | 編號 主鍵 | |
name | varchar | 100 | 應用名稱 |
app_key | varchar | 100 | 應用key(唯一) |
app_secret | varchar | 100 | 應用安全碼 |
available | bool | 是否鎖定 |
授權(sys_authorization)
名稱 | 型別 | 長度 | 描述 |
id | bigint | 編號 主鍵 | |
user_id | bigint | 所屬使用者 | |
app_id | bigint | 所屬應用 | |
role_ids | varchar | 100 | 角色列表 |
使用者:比《第十六章 綜合例項》少了role_ids,因為本章是多專案集中許可權管理;所以授權時需要指定相應的應用;而不是直接給使用者授權;所以不能在使用者中出現role_ids了;
應用:所有集中許可權的應用;在此處需要指定應用key(app_key)和應用安全碼(app_secret),app在訪問server時需要指定自己的app_key和使用者名稱來獲取該app對應使用者許可權資訊;另外app_secret可以認為app的密碼,比如需要安全訪問時可以考慮使用它,可參考《第二十章 無狀態Web應用整合》。另外available屬性表示該應用當前是否開啟;如果false表示該應用當前不可用,即不能獲取到相應的許可權資訊。
授權:給指定的使用者在指定的app下授權,即角色是與使用者和app存在關聯關係。
因為本章使用了《第十六章 綜合例項》程式碼,所以還有其他相應的表結構(本章未使用到)。
表/資料SQL
具體請參考
sql/ shiro-schema.sql (表結構)
sql/ shiro-data.sql (初始資料)
實體
具體請參考com.github.zhangkaitao.shiro.chapter23.entity包下的實體,此處就不列舉了。
DAO
具體請參考com.github.zhangkaitao.shiro.chapter23.dao包下的DAO介面及實現。
Service
具體請參考com.github.zhangkaitao.shiro.chapter23.service包下的Service介面及實現。以下是出了基本CRUD之外的關鍵介面:
Java程式碼- public interface AppService {
- public Long findAppIdByAppKey(String appKey);// 根據appKey查詢AppId
- }
- public interface AuthorizationService {
- //根據AppKey和使用者名稱查詢其角色
- public Set<String> findRoles(String appKey, String username);
- //根據AppKey和使用者名稱查詢許可權字串
- public Set<String> findPermissions(String appKey, String username);
- }
根據AppKey和使用者名稱查詢使用者在指定應用中對於的角色和許可權字串。
UserRealm
Java程式碼- protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
- String username = (String)principals.getPrimaryPrincipal();
- SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
- authorizationInfo.setRoles(
- authorizationService.findRoles(Constants.SERVER_APP_KEY, username));
- authorizationInfo.setStringPermissions(
- authorizationService.findPermissions(Constants.SERVER_APP_KEY, username));
- return authorizationInfo;
- }
此處需要呼叫AuthorizationService的findRoles/findPermissions方法傳入AppKey和使用者名稱來獲取使用者的角色和許可權字串集合。其他的和《第十六章 綜合例項》程式碼一樣。
ServerFormAuthenticationFilter
Java程式碼- public class ServerFormAuthenticationFilter extends FormAuthenticationFilter {
- protected void issueSuccessRedirect(ServletRequest request, ServletResponse response) throws Exception {
- String fallbackUrl = (String) getSubject(request, response)
- .getSession().getAttribute("authc.fallbackUrl");
- if(StringUtils.isEmpty(fallbackUrl)) {
- fallbackUrl = getSuccessUrl();
- }
- WebUtils.redirectToSavedRequest(request, response, fallbackUrl);
- }
- }
因為是多專案登入,比如如果是從其他應用中重定向過來的,首先檢查Session中是否有“authc.fallbackUrl”屬性,如果有就認為它是預設的重定向地址;否則使用Server自己的successUrl作為登入成功後重定向到的地址。
MySqlSessionDAO
將會話持久化到Mysql資料庫;此處大家可以將其實現為如儲存到Redis/Memcached等,實現策略請參考《第十章 會話管理》中的會話儲存/持久化章節的MySessionDAO,完全一樣。
MySqlSessionValidationScheduler
和《第十章 會話管理》中的會話驗證章節部分中的MySessionValidationScheduler完全一樣。如果使用如Redis之類的有自動過期策略的DB,完全可以不用實現SessionValidationScheduler,直接藉助於這些DB的過期策略即可。
RemoteService
Java程式碼- public class RemoteService implements RemoteServiceInterface {
- @Autowired private AuthorizationService authorizationService;
- @Autowired private SessionDAO sessionDAO;
- public Session getSession(String appKey, Serializable sessionId) {
- return sessionDAO.readSession(sessionId);
- }
- public Serializable createSession(Session session) {
- return sessionDAO.create(session);
- }
- public void updateSession(String appKey, Session session) {
- sessionDAO.update(session);
- }
- public void deleteSession(String appKey, Session session) {
- sessionDAO.delete(session);
- }
- public PermissionContext getPermissions(String appKey, String username) {
- PermissionContext permissionContext = new PermissionContext();
- permissionContext.setRoles(authorizationService.findRoles(appKey, username));
- permissionContext.setPermissions(authorizationService.findPermissions(appKey, username));
- return permissionContext;
- }
- }
將會使用HTTP呼叫器暴露為遠端服務,這樣其他應用就可以使用相應的客戶端呼叫這些介面進行Session的集中維護及根據AppKey和使用者名稱獲取角色/許可權字串集合。此處沒有實現安全校驗功能,如果是區域網內使用可以通過限定IP完成;否則需要使用如《第二十章 無狀態Web應用整合》中的技術完成安全校驗。
然後在spring-mvc-remote-service.xml配置檔案把服務暴露出去:
Java程式碼- <bean id="remoteService"
- class="com.github.zhangkaitao.shiro.chapter23.remote.RemoteService"/>
- <bean name="/remoteService"
- class="org.springframework.remoting.httpinvoker.HttpInvokerServiceExporter">
- <property name="service" ref="remoteService"/>
- <property name="serviceInterface"
- value="com.github.zhangkaitao.shiro.chapter23.remote.RemoteServiceInterface"/>
- </bean>
Shiro配置檔案spring-config-shiro.xml
和《第十六章 綜合例項》配置類似,但是需要在shiroFilter中的filterChainDefinitions中新增如下配置,即遠端呼叫不需要身份認證:
Java程式碼- /remoteService = anon
對於userRealm的快取配置直接禁用;因為如果開啟,修改了使用者許可權不會自動同步到快取;另外請參考《第十一章 快取機制》進行快取的正確配置。
伺服器端資料維護
2、輸入預設的使用者名稱密碼:admin/123456登入
3、應用管理,進行應用的CRUD,主要維護應用KEY(必須唯一)及應用安全碼;客戶端就可以使用應用KEY獲取使用者對應應用的許可權了。
4、授權管理,維護在哪個應用中使用者的角色列表。這樣客戶端就可以根據應用KEY及使用者名稱獲取到對應的角色/許可權字串列表了。
shiro-example-chapter23-client模組
Client模組提供給其他應用模組依賴,這樣其他應用模組只需要依賴Client模組,然後再在相應的配置檔案中配置如登入地址、遠端介面地址、攔截器鏈等等即可,簡化其他應用模組的配置。
配置遠端服務spring-client-remote-service.xml
Java程式碼- <bean id="remoteService"
- class="org.springframework.remoting.httpinvoker.HttpInvokerProxyFactoryBean">
- <property name="serviceUrl" value="${client.remote.service.url}"/>
- <property name="serviceInterface"
- value="com.github.zhangkaitao.shiro.chapter23.remote.RemoteServiceInterface"/>
- </bean>
client.remote.service.url是遠端服務暴露的地址;通過相應的properties配置檔案配置,後續介紹。然後就可以通過remoteService獲取會話及角色/許可權字串集合了。
ClientRealm
Java程式碼- public class ClientRealm extends AuthorizingRealm {
- private RemoteServiceInterface remoteService;
- private String appKey;
- public void setRemoteService(RemoteServiceInterface remoteService) {
- this.remoteService = remoteService;
- }
- public void setAppKey(String appKey) {
- this.appKey = appKey;
- }
- protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
- String username = (String) principals.getPrimaryPrincipal();
- SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
- PermissionContext context = remoteService.getPermissions(appKey, username);
- authorizationInfo.setRoles(context.getRoles());
- authorizationInfo.setStringPermissions(context.getPermissions());
- return authorizationInfo;
- }
- protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
- //永遠不會被呼叫
- throw new UnsupportedOperationException("永遠不會被呼叫");
- }
- }
ClientRealm提供身份認證資訊和授權資訊,此處因為是其他應用依賴客戶端,而這些應用不會實現身份認證,所以doGetAuthenticationInfo獲取身份認證資訊直接無須實現。另外獲取授權資訊,是通過遠端暴露的服務RemoteServiceInterface獲取,提供appKey和使用者名稱獲取即可。
ClientSessionDAO
Java程式碼- public class ClientSessionDAO extends CachingSessionDAO {
- private RemoteServiceInterface remoteService;
- private String appKey;
- public void setRemoteService(RemoteServiceInterface remoteService) {
- this.remoteService = remoteService;
- }
- public void setAppKey(String appKey) {
- this.appKey = appKey;
- }
- protected void doDelete(Session session) {
- remoteService.deleteSession(appKey, session);
- }
- protected void doUpdate(Session session) {
- remoteService.updateSession(appKey, session);
- }
- protected Serializable doCreate(Session session) {
- Serializable sessionId = remoteService.createSession(session);
- assignSessionId(session, sessionId);
- return sessionId;
- }
- protected Session doReadSession(Serializable sessionId) {
- return remoteService.getSession(appKey, sessionId);
- }
- }
Session的維護通過遠端暴露介面實現,即本地不維護會話。
ClientAuthenticationFilter
Java程式碼- public class ClientAuthenticationFilter extends AuthenticationFilter {
- protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
- Subject subject = getSubject(request, response);
- return subject.isAuthenticated();
- }
- protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
- String backUrl = request.getParameter("backUrl");
- saveRequest(request, backUrl, getDefaultBackUrl(WebUtils.toHttp(request)));
- return false;
- }
- protected void saveRequest(ServletRequest request, String backUrl, String fallbackUrl) {
- Subject subject = SecurityUtils.getSubject();
- Session session = subject.getSession();
- HttpServletRequest httpRequest = WebUtils.toHttp(request);
- session.setAttribute("authc.fallbackUrl", fallbackUrl);
- SavedRequest savedRequest = new ClientSavedRequest(httpRequest, backUrl);
- session.setAttribute(WebUtils.SAVED_REQUEST_KEY, savedRequest);
- }
- private String getDefaultBackUrl(HttpServletRequest request) {
- String scheme = request.getScheme();
- String domain = request.getServerName();
- int port = request.getServerPort();
- String contextPath = request.getContextPath();
- StringBuilder backUrl = new StringBuilder(scheme);
- backUrl.append("://");
- backUrl.append(domain);
- if("http".equalsIgnoreCase(scheme) && port != 80) {
- backUrl.append(":").append(String.valueOf(port));
- } else if("https".equalsIgnoreCase(scheme) && port != 443) {
- backUrl.append(":").append(String.valueOf(port));
- }
- backUrl.append(contextPath);
- backUrl.append(getSuccessUrl());
- return backUrl.toString();
- }
- }
ClientAuthenticationFilter是用於實現身份認證的攔截器(authc),當用戶沒有身份認證時;
1、首先得到請求引數backUrl,即登入成功重定向到的地址;
2、然後儲存儲存請求到會話,並重定向到登入地址(server模組);
3、登入成功後,返回地址按照如下順序獲取:backUrl、儲存的當前請求地址、defaultBackUrl(即設定的successUrl);
ClientShiroFilterFactoryBean
Java程式碼- public class ClientShiroFilterFactoryBean extends ShiroFilterFactoryBean implements ApplicationContextAware {
- private ApplicationContext applicationContext;
- public void setApplicationContext(ApplicationContext applicationContext) {
- this.applicationContext = applicationContext;
- }
- public void setFiltersStr(String filters) {
- if(StringUtils.isEmpty(filters)) {
- return;
- }
- String[] filterArray = filters.split(";");
- for(String filter : filterArray) {
- String[] o = filter.split("=");
- getFilters().put(o[0], (Filter)applicationContext.getBean(o[1]));
- }
- }
- public void setFilterChainDefinitionsStr(String filterChainDefinitions) {
- if(StringUtils.isEmpty(filterChainDefinitions)) {
- return;
- }
- String[] chainDefinitionsArray = filterChainDefinitions.split(";");
- for(String filter : chainDefinitionsArray) {
- String[] o = filter.split("=");
- getFilterChainDefinitionMap().put(o[0], o[1]);
- }
- }
- }
1、setFiltersStr:設定攔截器,設定格式如“filterName=filterBeanName; filterName=filterBeanName”;多個之間分號分隔;然後通過applicationContext獲取filterBeanName對應的Bean註冊到攔截器Map中;
2、setFilterChainDefinitionsStr:設定攔截器鏈,設定格式如“url=filterName1[config],filterName2; url=filterName1[config],filterName2”;多個之間分號分隔;
Shiro客戶端配置spring-client.xml
提供了各應用通用的Shiro客戶端配置;這樣應用只需要匯入相應該配置即可完成Shiro的配置,簡化了整個配置過程。
Java程式碼- <context:property-placeholder location=
- "classpath:client/shiro-client-default.properties,classpath:client/shiro-client.properties"/>
提供給客戶端配置的properties屬性檔案,client/shiro-client-default.properties是客戶端提供的預設的配置;classpath:client/shiro-client.properties是用於覆蓋客戶端預設配置,各應用應該提供該配置檔案,然後提供各應用個性配置。
Java程式碼- <bean id="remoteRealm" class="com.github.zhangkaitao.shiro.chapter23.client.ClientRealm">
- <property name="cachingEnabled" value="false"/>
- <property name="appKey" value="${client.app.key}"/>
- <property name="remoteService" ref="remoteService"/>
- </bean>
appKey:使用${client.app.key}佔位符替換,即需要在之前的properties檔案中配置。
Java程式碼- <bean id="sessionIdCookie" class="org.apache.shiro.web.servlet.SimpleCookie">
- <constructor-arg value="${client.session.id}"/>
- <property name="httpOnly" value="true"/>
- <property name="maxAge" value="-1"/>
- <property name="domain" value="${client.cookie.domain}"/>
- <property name="path" value="${client.cookie.path}"/>
- </bean>
Session Id Cookie,cookie名字、域名、路徑等都是通過配置檔案配置。
Java程式碼- <bean id="sessionDAO"
- class="com.github.zhangkaitao.shiro.chapter23.client.ClientSessionDAO">
- <property name="sessionIdGenerator" ref="sessionIdGenerator"/>
- <property name="appKey" value="${client.app.key}"/>
- <property name="remoteService" ref="remoteService"/>
- </bean>
SessionDAO的appKey,也是通過${ client.app.key }佔位符替換,需要在配置檔案配置。
Java程式碼- <bean id="sessionManager"
- class="org.apache.shiro.web.session.mgt.DefaultWebSessionManager">
- <property name="sessionValidationSchedulerEnabled" value="false"/>//省略其他
- </bean>
其他應用無須進行會話過期排程,所以sessionValidationSchedulerEnabled=false。
Java程式碼- <bean id="clientAuthenticationFilter"
- class="com.github.zhangkaitao.shiro.chapter23.client.ClientAuthenticationFilter"/>
應用的身份認證使用ClientAuthenticationFilter,即如果沒有身份認證,則會重定向到Server模組完成身份認證,身份認證成功後再重定向回來。
Java程式碼- <bean id="shiroFilter"
- class="com.github.zhangkaitao.shiro.chapter23.client.ClientShiroFilterFactoryBean">
- <property name="securityManager" ref="securityManager"/>
- <property name="loginUrl" value="${client.login.url}"/>
- <property name="successUrl" value="${client.success.url}"/>
- <property name="unauthorizedUrl" value="${client.unauthorized.url}"/>
- <property name="filters">
- <util:map>
- <entry key="authc" value-ref="clientAuthenticationFilter"/>
- </util:map>
- </property>
- <property name="filtersStr" value="${client.filters}"/>
- <property name="filterChainDefinitionsStr" value="${client.filter.chain.definitions}"/>
- </bean>
ShiroFilter使用我們自定義的ClientShiroFilterFactoryBean,然後loginUrl(登入地址)、successUrl(登入成功後預設的重定向地址)、unauthorizedUrl(未授權重定向到的地址)通過佔位符替換方式配置;另外filtersStr和filterChainDefinitionsStr也是使用佔位符替換方式配置;這樣就可以在各應用進行自定義了。