不用 Spring Security 可否?試試這個小而美的安全框架
- 2019 年 10 月 3 日
- 筆記
寫在前面
在一款應用的整個生命周期,我們都會談及該應用的數據安全問題。用戶的合法性與數據的可見性是數據安全中非常重要的一部分。但是,一方面,不同的應用對於數據的合法性和可見性要求的維度與粒度都有所區別;另一方面,以當前微服務、多服務的架構方式,如何共享Session,如何快取認證和授權數據應對高並發訪問都迫切需要我們解決。Shiro的出現讓我們可以快速和簡單的應對我們應用的數據安全問題
Shiro介紹
Shiro簡介
這個官網解釋不抽象,所以直接用官網解釋:Apache Shiro™是一個強大且易用的 Java 安全框架,可以執行身份驗證、授權、加密和會話管理等。基於 Shiro 的易於理解的API,您可以快速、輕鬆地使任何應用程式變得安全(從最小的移動應用到最大的網路和企業應用)。
談及安全,多數 Java 開發人員都離不開 Spring 框架的支援,自然也就會先想到 Spring Security,那我們先來看二者的差別
Shiro | Spring Security |
---|---|
簡單、靈活 | 複雜、笨重 |
可脫離Spring | 不可脫離Spring |
粒度較粗 | 粒度較細 |
雖然 Spring Security 屬於名震中外 Spring 家族的一部分,但是了解 Shiro 之後,你不會想 「嫁入豪門」,而是選擇追求「詩和遠方」衝動。
橫看成嶺側成峰,遠近高低各不同 (依舊是先了解概念就好)
遠看 Shiro 看輪廓
Subject
它是一個主體,代表了當前「用戶」,這個用戶不一定是一個具體的人,與當前應用交互的任何東西都是Subject,如網路爬蟲,機器人等;即一個抽象概念;所有 Subject 都綁定到 SecurityManager,與 Subject 的所有交互都會委託給SecurityManager;可以把 Subject 認為是一個門面;SecurityManager 才是實際的執行者
SecurityManager
安全管理器;即所有與安全有關的操作都會與 SecurityManager 交互;且它管理著所有 Subject;可以看出它是 Shiro 的核心,它負責與後邊介紹的其他組件進行交互,如果學習過 SpringMVC,你可以把它看成 DispatcherServlet前端控制器
Realm
域,Shiro 從 Realm 獲取安全數據(如用戶、角色、許可權),就是說 SecurityManager 要驗證用戶身份,那麼它需要從 Realm 獲取相應的用戶進行比較以確定用戶身份是否合法;也需要從 Realm 得到用戶相應的角色/許可權進行驗證用戶是否能進行操作;可以把 Realm 看成 DataSource,即安全數據源。
近看 Shiro 看細節
看圖瞬間懵逼?別慌,會為你拆解來看,結合著圖看下面的解釋,這不是啥大問題,且看:
Subject
主體,可以看到主體可以是任何可以與應用交互的 「用戶」
SecurityManager
相當於 SpringMVC 中的 DispatcherServlet;是 Shiro 的心臟;所有具體的交互都通過 SecurityManager 進行控制;它管理著所有 Subject、且負責進行認證和授權、及會話、快取的管理
Authenticator
認證器,負責主體認證的,這是一個擴展點,如果用戶覺得 Shiro 默認的不好,可以自定義實現;需要自定義認證策略(Authentication Strategy),即什麼情況下算用戶認證通過了
Authrizer
授權器,或者訪問控制器,用來決定主體是否有許可權進行相應的操作;即控制著用戶能訪問應用中的哪些功能
Realm
可以有 1 個或多個 Realm,可以認為是安全實體數據源,即用於獲取安全實體的;可以是JDBC實現,也可以是LDAP實現,或者記憶體實現等等;由用戶提供;注意:Shiro 不知道你的用戶/許可權存儲在哪及以何種格式存儲;所以我們一般在應用中都需要實現自己的Realm
SessionManager
如果寫過 Servlet 就應該知道 Session 的概念,Session 需要有人去管理它的生命周期,這個組件就是 SessionManager;而Shiro 並不僅僅可以用在 Web 環境,也可以用在如普通的 JavaSE 環境、EJB等環境;所以,Shiro 就抽象了一個自己的Session 來管理主體與應用之間交互的數據;這樣的話,比如我們在 Web 環境用,剛開始是一台Web伺服器;接著又上了台EJB 伺服器;這時又想把兩台伺服器的會話數據放到一個地方,我們就可以實現自己的分散式會話(如把數據放到Memcached 伺服器)
SessionDAO
DAO大家都用過,數據訪問對象,用於會話的 CRUD,比如我們想把 Session 保存到資料庫,那麼可以實現自己的SessionDAO,通過如JDBC寫到資料庫;比如想把 Session 放到 Memcached 中,可以實現自己的 Memcached SessionDAO;另外 SessionDAO 中可以使用 Cache 進行快取,以提高性能;
CacheManager
快取控制器,來管理如用戶、角色、許可權等的快取的;因為這些數據基本上很少去改變,放到快取中後可以提高訪問的性能
Cryptography
密碼模組,Shiro提高了一些常見的加密組件用於如密碼「加密/解密」的
注意上圖的結構,我們會根據這張圖來逐步拆分講解,記住這張圖也更有助於我們理解 Shiro 的工作原理,所以依舊是打開兩個網頁一起看就好嘍
搭建概覽
多數小夥伴都在使用 Spring Boot, Shiro 也很應景的定義了 starter,做了更好的封裝,對於我們來說使用起來也就更加方便,來看選型概覽
序號 | 名稱 | 版本 |
---|---|---|
1 | Springboot | 2.0.4 |
2 | JPA | 2.0.4 |
3 | Mysql | 8.0.12 |
4 | Redis | 2.0.4 |
5 | Lombok | 1.16.22 |
6 | Guava | 26.0-jre |
7 | Shiro | 1.4.0 |
使用 Spring Boot,大多都是通過添加 starter 依賴,會自動解決依賴包版本,所以自己嘗試的時候用最新版本不會有什麼問題,比如 Shiro 現在的版本是 1.5.0 了,整體問題不大,大家自行嘗試就好
添加 Gradle 依賴管理
大體目錄結構
application.yml 配置
基本配置
你就讓我看這?這只是一個概覽,先做到心中有數,我們來看具體配置,逐步完成搭建
其中 shiroFilter bean 部分指定了攔截路徑和相應的過濾器,」/user/login」, 」/user」, 」/user/loginout」 可以匿名訪問,其他路徑都需要授權訪問,shiro 提供和多個默認的過濾器,我們可以用這些過濾器來配置控制指定url的許可權(先了解個大概即可):
配置縮寫 | 對應的過濾器 | 功能 |
---|---|---|
anon | AnonymousFilter | 指定url可以匿名訪問 |
authc | FormAuthenticationFilter | 指定url需要form表單登錄,默認會從請求中獲取username、password,rememberMe等參數並嘗試登錄,如果登錄不了就會跳轉到loginUrl配置的路徑。我們也可以用這個過濾器做默認的登錄邏輯,但是一般都是我們自己在控制器寫登錄邏輯的,自己寫的話出錯返回的資訊都可以訂製嘛。 |
authcBasic | BasicHttpAuthenticationFilter | 指定url需要basic登錄 |
Logout | LogoutFilter | 登出過濾器,配置指定url就可以實現退出功能,非常方便 |
noSessionCreation | NoSessionCreationFilter | 禁止創建會話 |
perms | PermissionsAuthorizationFilter | 需要指定許可權才能訪問 |
port | PortFilter | 需要指定埠才能訪問 |
rest | HttpMethodPermissionFilter | 將http請求方法轉化成相應的動詞來構造一個許可權字元串,這個感覺意義不大,有興趣自己看源碼的注釋 |
roles | RolesAuthorizationFilter | 需要指定角色才能訪問 |
ssl | SslFilter | 需要https請求才能訪問 |
user | UserFilter | 需要已登錄或「記住我」的用戶才能訪問 |
資料庫表設計
資料庫表設計請參考 entity package下的 bean,通過@Entity 註解與 JPA 的設置自動生成表結構 (你需要簡單的了解一下 JPA 的功能)。
我們要說重點啦~~~
身份認證
身份認證是一個證明 「李雷是李雷,韓梅梅是韓梅梅」 的過程,回看上圖,Realm 模組就是用來做這件事的,Shiro 提供了 IniRealm,JdbcReaml,LDAPReam等認證方式,但自定義的 Realm 通常是最適合我們業務需要的,認證通常是校驗登錄用戶是否合法。
新建用戶 User
@Data @Entity public class User implements Serializable { @Id @GeneratedValue(strategy=GenerationType.AUTO) private Long id; @Column(unique =true) private String username; private String password; private String salt; }
定義 Repository
@Repository public interface UserRepository extends JpaRepository<User, Long> { public User findUserByUsername(String username); }
編寫UserController:
@GetMapping("/login") public void login(String username, String password) { UsernamePasswordToken token = new UsernamePasswordToken(username, password); token.setRememberMe(true); Subject currentUser = SecurityUtils.getSubject(); currentUser.login(token); }
自定義 Realm
自定義 Realm,主要是為了重寫 doGetAuthenticationInfo(…)方法
@Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException { UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken; String username = token.getUsername(); User user = userRepository.findUserByUsername(username); SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(user, user.getPassword(), getName()); simpleAuthenticationInfo.setCredentialsSalt(ByteSource.Util.bytes(user.getSalt())); return simpleAuthenticationInfo; }
這些程式碼我需要做一個說明,你可能也滿肚子疑惑:
- 這段程式碼怎麼應用了 shiro?
- controller 是怎麼調用到 custom realm 的?
- 重寫的 doGetAuthenticationInfo(…) 方法目的是什麼?
認證流程說明
用戶訪問/user/login
路徑,生成 UsernamePasswordToken, 通過SecurityUtils.getSubject()獲取Subject(currentUser),調用 login 方法進行驗證,讓我們跟蹤一下程式碼,瞧一瞧就知道自定義的CustomRealm怎樣起作用的,一起來看源碼:
到這裡我們要停一停了,請回看 Shiro 近景圖,將源碼追蹤路徑與其對比,是完全一致的
授權
身份認證是驗證你是誰的問題,而授權是你能幹什麼的問題,
產品經理:申購模組只能科室看
程式設計師:好的
產品經理:科長許可權大一些,他也能看申購模組
程式設計師:好的(黑臉)
產品經理:科長不但能看,還能修改數據
程式設計師:關公提大刀,拿命來
…
作為程式設計師,我們的宗旨是:「能動手就不吵吵」; 硝煙怒火拔地起,耳邊響起駝鈴聲(Shiro):「放下屠刀,立地成佛」授權沒有那麼麻煩,大家好商量…
整個過程和身份認證基本是一毛一樣,你對比看看
角色實體創建
涉及到授權,自然要和角色相關,所以我們創建 Role 實體:
@Data @Entity public class Role { @Id @GeneratedValue(strategy=GenerationType.AUTO) private Long id; @Column(unique =true) private String roleCode; private String roleName; }
新建 Role Repository
@Repository public interface RoleRepository extends JpaRepository<Role, Long> { @Query(value = "select roleId from UserRoleRel ur where ur.userId = ?1") List<Long> findUserRole(Long userId); List<Role> findByIdIn(List<Long> ids); }
定義許可權實體 Permission
@Data @Entity public class Permission { @Id @GeneratedValue(strategy=GenerationType.AUTO) private Long id; @Column(unique =true) private String permCode; private String permName; }
定義 Permission Repository
@Repository public interface PermissionRepository extends JpaRepository<Permission, Long> { @Query(value = "select permId from RolePermRel pr where pr.roleId in ?1") List<Long> findRolePerm(List<Long> roleIds); List<Permission> findByIdIn(List<Long> ids); }
建立用戶與角色關係
其實可以通過 JPA 註解來制定關係的,這裡為了說明問題,以單獨外鍵形式說明
@Data @Entity public class UserRoleRel { @Id @GeneratedValue(strategy=GenerationType.AUTO) private Long id; private Long userId; private Long roleId; }
建立角色與許可權關係
@Data @Entity public class RolePermRel { @Id @GeneratedValue(strategy=GenerationType.AUTO) private Long id; private Long permId; private Long roleId; }
編寫 UserController
@RequiresPermissions("user:list:view") @GetMapping() public void getAllUsers(){ List<User> users = userRepository.findAll(); }
@RequiresPermissions("user:list:view")
註解說明具有用戶:列表:查看許可權的才可以訪問),官網明確給出許可權定義格式,包括通配符等,我希望你自行去查看
自定義 CustomRealm (主要重寫 doGetAuthorizationInfo) 方法:
與認證流程如出一轍,只不過多了用戶,角色,許可權的關係罷了
授權流程說明
這裡通過過濾器(見Shiro配置)和註解二者結合的方式來進行授權,和認證流程一樣,最終會走到我們自定義的 CustomRealm 中,同樣 Shiro 默認提供了許多註解用來處理不同的授權情況
註解 | 功能 |
---|---|
@RequiresGuest | 只有遊客可以訪問 |
@RequiresAuthentication | 需要登錄才能訪問 |
@RequiresUser | 已登錄的用戶或「記住我」的用戶能訪問 |
@RequiresRoles | 已登錄的用戶需具有指定的角色才能訪問 |
@RequiresPermissions | 已登錄的用戶需具有指定的許可權才能訪問(如果不想和產品經理華山論劍,推薦用這個註解) |
授權官網給出明確的授權策略與案例,請查看:http://shiro.apache.org/permissions.html
上面的例子我們通過一直在通過訪問 Mysql 獲取用戶認證和授權資訊,這中方式明顯不符合生產環境的需求
Session會話管理
做過 Web 開發的同學都知道 Session 的概念,最常用的是 Session 過期時間,數據在 Session 的 CRUD,同樣看上圖,我們需要關注 SessionManager 和 SessionDAO 模組,Shiro starter 已經提供了基本的 Session配置資訊,我們按需在YAML中配置就好(官網https://shiro.apache.org/spring-boot.html 已經明確給出Session的配置資訊)
Key | Default Value | Description |
---|---|---|
shiro.enabled | true | Enables Shiro』s Spring module |
shiro.web.enabled | true | Enables Shiro』s Spring web module |
shiro.annotations.enabled | true | Enables Spring support for Shiro』s annotations |
shiro.sessionManager.deleteInvalidSessions | true | Remove invalid session from session storage |
shiro.sessionManager.sessionIdCookieEnabled | true | Enable session ID to cookie, for session tracking |
shiro.sessionManager.sessionIdUrlRewritingEnabled | true | Enable session URL rewriting support |
shiro.userNativeSessionManager | false | If enabled Shiro will manage the HTTP sessions instead of the container |
shiro.sessionManager.cookie.name | JSESSIONID | Session cookie name |
shiro.sessionManager.cookie.maxAge | -1 | Session cookie max age |
shiro.sessionManager.cookie.domain | null | Session cookie domain |
shiro.sessionManager.cookie.path | null | Session cookie path |
shiro.sessionManager.cookie.secure | false | Session cookie secure flag |
shiro.rememberMeManager.cookie.name | rememberMe | RememberMe cookie name |
shiro.rememberMeManager.cookie.maxAge | one year | RememberMe cookie max age |
shiro.rememberMeManager.cookie.domain | null | RememberMe cookie domain |
shiro.rememberMeManager.cookie.path | null | RememberMe cookie path |
shiro.rememberMeManager.cookie.secure | false | RememberMe cookie secure flag |
shiro.loginUrl | /login.jsp | Login URL used when unauthenticated users are redirected to login page |
shiro.successUrl | / | Default landing page after a user logs in (if alternative cannot be found in the current session) |
shiro.unauthorizedUrl | null | Page to redirect user to if they are unauthorized (403 page) |
分散式服務中,我們通常需要將Session資訊放入Redis中來管理,來應對高並發的訪問需求,這時只需重寫SessionDAO即可完成自定義的Session管理
整合Redis
@Configuration public class RedisConfig { @Autowired private RedisConnectionFactory redisConnectionFactory; @Bean public RedisTemplate<String, Object> stringObjectRedisTemplate() { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(redisConnectionFactory); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); return template; } }
重寫SessionDao
查看源碼,可以看到調用默認SessionManager的retriveSession方法,我們重寫該方法,將Session放入HttpRequest中,進一步提高session訪問效率
向ShiroConfig中添加配置
其實在概覽模組已經給出程式碼展示,這裡單獨列出來做說明:
/** * 自定義RedisSessionDao用來管理Session在Redis中的CRUD * @return */ @Bean(name = "redisSessionDao") public RedisSessionDao redisSessionDao(){ return new RedisSessionDao(); } /** * 自定義SessionManager,應用自定義SessionDao * @return */ @Bean(name = "customerSessionManager") public CustomerWebSessionManager customerWebSessionManager(){ CustomerWebSessionManager customerWebSessionManager = new CustomerWebSessionManager(); customerWebSessionManager.setSessionDAO(redisSessionDao()); return customerWebSessionManager; } /** * 定義Security manager * @param customRealm * @return */ @Bean(name = "securityManager") public DefaultWebSecurityManager defaultWebSecurityManager(CustomRealm customRealm) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager (); securityManager.setRealm(customRealm); securityManager.setSessionManager(customerWebSessionManager()); // 可不指定,Shiro會用默認Session manager securityManager.setCacheManager(redisCacheManagers()); //可不指定,Shiro會用默認CacheManager // securityManager.setSessionManager(defaultWebSessionManager()); return securityManager; } /** * 定義session管理器 * @return */ @Bean(name = "sessionManager") public DefaultWebSessionManager defaultWebSessionManager(){ DefaultWebSessionManager defaultWebSessionManager = new DefaultWebSessionManager(); defaultWebSessionManager.setSessionDAO(redisSessionDao()); return defaultWebSessionManager; }
至此,將 session 資訊由 redis 管理功能就這樣完成了
快取管理
應對分散式服務,對於高並發訪問資料庫許可權內容是非常低效的方式,同樣我們可以利用Redis來解決這一問題,將授權數據快取到Redis中
新建 RedisCache
@Slf4j @Component public class RedisCache<K, V> implements Cache<K, V> { public static final String SHIRO_PREFIX = "shiro-cache:"; @Resource private RedisTemplate<String, Object> stringObjectRedisTemplate; private String getKey(K key){ if (key instanceof String){ return (SHIRO_PREFIX + key); } return key.toString(); } @Override public V get(K k) throws CacheException { log.info("read from redis..."); V v = (V) stringObjectRedisTemplate.opsForValue().get(getKey(k)); if (v != null){ return v; } return null; } @Override public V put(K k, V v) throws CacheException { stringObjectRedisTemplate.opsForValue().set(getKey(k), v); stringObjectRedisTemplate.expire(getKey(k), 100, TimeUnit.SECONDS); return v; } @Override public V remove(K k) throws CacheException { V v = (V) stringObjectRedisTemplate.opsForValue().get(getKey(k)); stringObjectRedisTemplate.delete((String) get(k)); if (v != null){ return v; } return null; } @Override public void clear() throws CacheException { //不要重寫,如果只保存shiro數據無所謂 } @Override public int size() { return 0; } @Override public Set<K> keys() { return null; } @Override public Collection<V> values() { return null; } }
新建 RedisCacheManager
public class RedisCacheManager implements CacheManager { @Resource private RedisCache redisCache; @Override public <K, V> Cache<K, V> getCache(String s) throws CacheException { return redisCache; } }
至此,我們不用每次訪問 Mysql DB 來獲取認證和授權資訊,而是通過 Redis 來快取這些資訊,大大提升了效率,也滿足分散式系統的設計需求
總結
回復公眾號 「demo」獲取 demo 程式碼。這裡只是梳理了Springboot整合Shiro的流程,以及應用Redis最大化利用Shiro,Shiro的使用細節還很多,官網說的也很明確,帶著上面的架構圖來理解Shiro會事半功倍,感覺這裡面的程式碼挺多挺頭大的?那是你沒有自己動手去嘗試,結合官網與 demo 相信你會對 Shiro 有更好的理解,另外你可以理解 Shiro 是 mini 版本的 Spring Security,我希望以小見大,當需要更細粒度的認證授權時,也會對理解 Spring Security 有很大幫助,點擊文末「閱讀原文」,效果更好
落霞與孤鶩齊飛 秋水共長天一色,產品經理和程式設計師一片祥和…
靈魂追問
- 都說 Redis 是單執行緒,但是很快,你知道為什麼嗎?
- 你們項目中是怎樣控制認證授權的呢?當授權有變化,對於程式設計師來說,這個修改是災難嗎?
提高效率工具
MarkDown 表格生成器
本文的好多表格是從官網粘貼的,如何將其直接轉換成 MD table 呢?那麼 https://www.tablesgenerator.com/markdown_tables 就可以幫到你了,無論是生成 MD table,還是粘貼內容生成 table 和內容都是極好的,當然了不止 MD table,自己發現吧,更多工具,公眾號回復 「工具」獲得
推薦閱讀
- 只會用 git pull ?有時候你可以嘗試更優雅的處理方式
- 雙親委派模型:大廠高頻面試題,輕鬆搞定
- 面試還不知道BeanFactory和ApplicationContext的區別?
- 如何設計好的RESTful API
- 程式猿為什麼要看源碼?
歡迎持續關注公眾號:「日拱一兵」
- 前沿 Java 技術乾貨分享
- 高效工具匯總 回復「工具」
- 面試問題分析與解答
- 技術資料領取 回復「資料」
以讀偵探小說思維輕鬆趣味學習 Java 技術棧相關知識,本著將複雜問題簡單化,抽象問題具體化和圖形化原則逐步分解技術問題,技術持續更新,請持續關注……