摘要:暑假的時候在學習了并成功運用到了項目中。這是提供的一個安全權限控制框架,可以根據使用者的需要定制相關的角色身份和身份所具有的權限,完成黑名單操作攔截無權限的操作。用戶通過登陸操作獲得我們返回的并保存在本地。
暑假的時候在學習了 Spring Security 并成功運用到了項目中。 在實踐中摸索出了一套結合 json + jwt(json web token) + Spring Boot + Spring Security 技術的權限方案趁著國慶假期記錄一下。
以下所有步驟的源碼可以從我的 github 上取得。如果要了解,請閱讀 readme.md。
各個技術的簡要介紹 json : 與前端交互的數據交換格式個人理解上,它的特點是可以促進 web 前后端解耦,提升團隊的工作效率。 同時也是跟安卓端和 iOS 端交互的工具,目前是沒想出除了 json 和 XML 之外的交流形式誒(或許等以后有空閑時間會看看)。
它的另一個特點是輕量級,簡潔和清晰的層次可以方便我們閱讀和編寫,并且減少服務器帶寬占用。
jwt (json web token)用人話講就是將用戶的身份信息(賬號名字)、其他信息(不固定,根據需要增加)在用戶登陸時提取出來,并且通過加密手段加工成一串密文,在用戶登陸成功時帶在返回結果發送給用戶。以后用戶每次請求時均帶上這串密文,服務器根據解析這段密文判斷用戶是否有權限訪問相關資源,并返回相應結果。
從網上摘錄了一些優點,關于 jwt 的更多資料感興趣的讀者可以自行谷歌:
相比于session,它無需保存在服務器,不占用服務器內存開銷。
無狀態、可拓展性強:比如有3臺機器(A、B、C)組成服務器集群,若session存在機器A上,session只能保存在其中一臺服務器,此時你便不能訪問機器B、C,因為B、C上沒有存放該Session,而使用token就能夠驗證用戶請求合法性,并且我再加幾臺機器也沒事,所以可拓展性好就是這個意思。
由 2 知,這樣做可就支持了跨域訪問。
Spring BootSpring Boot 是一個用來簡化 Spring 應用的搭建以及開發過程的框架。用完后會讓你大呼 : "wocao! 怎么有這么方便的東西! mama 再也不用擔心我不會配置 xml 配置文件了!"。
Spring Security這是 Spring Security 提供的一個安全權限控制框架,可以根據使用者的需要定制相關的角色身份和身份所具有的權限,完成黑名單操作、攔截無權限的操作。配合 Spring Boot 可以快速開發出一套完善的權限系統。
本次技術方案中 Spring Security 執行流程從圖中可以看出本次執行流程圍繞著的就是 token。
用戶通過登陸操作獲得我們返回的 token 并保存在本地。在以后每次請求都在請求頭中帶上 token ,服務器在收到客戶端傳來的請求時會判斷是否有 token ,若有,解析 token 并寫入權限到本次會話,若無直接跳過解析 token 的步驟,然后判斷本次訪問的接口是否需要認證,是否需要相應的權限,并根據本次會話中的認證情況做出反應。
動手實現這個安全框架 步驟一 : 建立項目,配置好數據源使用 Itellij Idea 建立一個 Spring Boot 項目
選擇 Web 、Security 、 Mybatis 和 JDBC 四個組件。
在數據庫中建立所需的數據庫 spring_security
在 spring boot 配置文件 application.properties 中配置好數據源
啟動項目查看 Spring Boot 是否替我們配置好 Spring Security 了。
若是正確啟動了,可以看到 Spring Security 生成了一段默認密碼。
我們訪問 localhost:8080 會彈出一個 basic 認證框
輸入 用戶名 user 密碼 前面自動生成的密碼 便可得到通過的返回消息(返回 404,因為我們還未建立任何頁面)
輸入 錯誤的用戶名或者密碼會返回 401 ,提示未認證
如果你走到了這一步,意味著你已經配置好了所需要的環境,接下來就跟著進入下一步吧!
步驟二 : 生成我們的 jwt在這一步我們將學習如何根據我們的需要生成我們定制的 token !
關閉 Spring Boot 替我們配置好的 Spring Security。(因為默認配置好的 Spring Security 會攔截掉我們定制的登陸接口)
創建 Spring Security 配置類 WebSecurityConfig.java
@Configuration // 聲明為配置類 @EnableWebSecurity // 啟用 Spring Security web 安全的功能 public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .anyRequest().permitAll() // 允許所有請求通過 .and() .csrf() .disable() // 禁用 Spring Security 自帶的跨域處理 .sessionManagement() // 定制我們自己的 session 策略 .sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 調整為讓 Spring Security 不創建和使用 session } }
在數據庫中建立相應的用戶和角色。
創建用戶表 user
其中各個屬性和作用如下:
username : 用戶名
password : 密碼
role_id : 用戶所屬角色編號
last_password_change : 最后一次密碼修改時間
enable : 是否啟用該賬號,可以用來做黑名單
創建角色表 role
其中各個屬性作用如下:
role_id : 角色相應 id
role_name : 角色的名稱
auth : 角色所擁有的權限
編寫相應的登陸密碼判斷邏輯
因為登陸功能很容易實現,這里就不寫出來占地方了哎。
編寫 token 操作類(生成 token 部分)
因為網上有造好的輪子,我們可以直接拿來做些修改就可以使用了。
使用 maven 導入網上造好的 jwt 輪子
io.jsonwebtoken jjwt 0.4
建立我們自己的 token 操作類 TokenUtils.java
public class TokenUtils { private final Logger logger = Logger.getLogger(this.getClass()); @Value("${token.secret}") private String secret; @Value("${token.expiration}") private Long expiration; /** * 根據 TokenDetail 生成 Token * * @param tokenDetail * @return */ public String generateToken(TokenDetail tokenDetail) { Mapclaims = new HashMap (); claims.put("sub", tokenDetail.getUsername()); claims.put("created", this.generateCurrentDate()); return this.generateToken(claims); } /** * 根據 claims 生成 Token * * @param claims * @return */ private String generateToken(Map claims) { try { return Jwts.builder() .setClaims(claims) .setExpiration(this.generateExpirationDate()) .signWith(SignatureAlgorithm.HS512, this.secret.getBytes("UTF-8")) .compact(); } catch (UnsupportedEncodingException ex) { //didn"t want to have this method throw the exception, would rather log it and sign the token like it was before logger.warn(ex.getMessage()); return Jwts.builder() .setClaims(claims) .setExpiration(this.generateExpirationDate()) .signWith(SignatureAlgorithm.HS512, this.secret) .compact(); } } /** * token 過期時間 * * @return */ private Date generateExpirationDate() { return new Date(System.currentTimeMillis() + this.expiration * 1000); } /** * 獲得當前時間 * * @return */ private Date generateCurrentDate() { return new Date(System.currentTimeMillis()); } }
這個工具類的目前做的事情是 :
把用戶名封裝進下載的輪子的 token 的主體 claims 中,并在里面封裝了當前時間(方便后面判斷 token 是否在修改密碼之前生成的)
再計算 token 過期的時間寫入到 輪子的 token 中
對 輪子的 token 進行撒鹽加密,生成一串字符串,即我們定制的 token
生成定制 token 的方法的入參 TokenDetail 的定義如下
public interface TokenDetail { //TODO: 這里封裝了一層,不直接使用 username 做參數的原因是可以方便未來增加其他要封裝到 token 中的信息 String getUsername(); } public class TokenDetailImpl implements TokenDetail { private final String username; public TokenDetailImpl(String username) { this.username = username; } @Override public String getUsername() { return this.username; } }
同時這個工具類把加密 token 撒鹽的字符串和 token 的過期時間提取到了 application.properties 中
# token 加密密鑰 token.secret=secret # token 過期時間,以秒為單位,604800 是 一星期 token.expiration=604800
至此,我們生成 token 的教程已經完成,至于登陸接口,判斷賬號密碼是否正確的操作就留給讀者去實現,讀者只需在登陸成功時在結果中返回生成好的 token 給用戶即可。
步驟三 : 實現驗證 token 是否有效,并根據 token 獲得賬號詳細信息(權限,是否處于封號狀態)的功能分析實現的過程
在步驟二中,我們把用戶的的 username 、 token 創建的時間 、 token 過期的時間封裝到了加密過后的 token 字符串中,就是為了服務此時我們驗證用戶權限的目的。
假設我們此時拿到了用戶傳遞過來的一串 token,并且要根據這串 token 獲得用戶的詳情可以這樣做:
A. 嘗試解析這串 token ,若成功解析出來,進入下一步,否則終止解析過程
B. 根據解析出來的 username 從數據庫中查找用戶的賬號,最后一次密碼修改的時間,權限,是否封號等用戶詳情信息,把這些信息封裝到一個實體類中(userDetail類)。若查找不到該用戶,終止解析進程
C. 檢查 userDetail 中記錄的封號狀態,若是賬號已被封號,返回封號結果,終止請求
D. 根據 userDtail 比較 token 是否處于有效期內,若不處于有效期內,終止解析過程,否則繼續
E. 將 userDetail 中記錄的用戶權限寫入本次請求會話中,解析完成。
可參考下圖理解:
下面開始動手實現
嘗試解析 token 獲得 username
/** * 從 token 中拿到 username * * @param token * @return */ public String getUsernameFromToken(String token) { String username; try { final Claims claims = this.getClaimsFromToken(token); username = claims.getSubject(); } catch (Exception e) { username = null; } return username; } /** * 解析 token 的主體 Claims * * @param token * @return */ private Claims getClaimsFromToken(String token) { Claims claims; try { claims = Jwts.parser() .setSigningKey(this.secret.getBytes("UTF-8")) .parseClaimsJws(token) .getBody(); } catch (Exception e) { claims = null; } return claims; }
在這段代碼中,我們先對 token 進行解密,獲得 token 中封裝好的主體部分 claims (前面第二部引入的 別人造好的輪子),然后嘗試獲得里面封裝的 username 字符串。
從數據庫中獲得用戶詳情 userDetail
這里我們將實現 Spring Security 的一個 UserDetailService 接口,這個接口只有一個方法, loadUserByUsername。流程圖如下
代碼如下:
public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private UserMapper userMapper; /** * 獲取 userDetail * @param username * @return * @throws UsernameNotFoundException */ @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = this.userMapper.getUserFromDatabase(username); if (user == null) { throw new UsernameNotFoundException(String.format("No user found with username "%s".", username)); } else { return SecurityModelFactory.create(user); } } } public class User implements LoginDetail, TokenDetail { private String username; private String password; private String authorities; private Long lastPasswordChange; private char enable; // 省略構造器和 getter setter 方法 } public class SecurityModelFactory { public static UserDetailImpl create(User user) { Collection extends GrantedAuthority> authorities; try { authorities = AuthorityUtils.commaSeparatedStringToAuthorityList(user.getAuthorities()); } catch (Exception e) { authorities = null; } Date lastPasswordReset = new Date(); lastPasswordReset.setTime(user.getLastPasswordChange()); return new UserDetailImpl( user.getUsername(), user.getUsername(), user.getPassword(), lastPasswordReset, authorities, user.enable() ); } }
其中獲得未處理過的用戶詳細信息 User 類的 mapper 類定義如下:
public interface UserMapper { User getUserFromDatabase(@Param("username") String username); }
相應的 xml 文件為 :
至此,我們已經完成獲取用戶詳細信息的的功能了。接下來只要限制接口的訪問權限,并要求用戶訪問接口時帶上 token 即可實現對權限的控制。
步驟四 : 定義解析 token 的攔截器老規矩,上流程圖:
下面定義這個攔截器
public class AuthenticationTokenFilter extends UsernamePasswordAuthenticationFilter { /** * json web token 在請求頭的名字 */ @Value("${token.header}") private String tokenHeader; /** * 輔助操作 token 的工具類 */ @Autowired private TokenUtils tokenUtils; /** * Spring Security 的核心操作服務類 * 在當前類中將使用 UserDetailsService 來獲取 userDetails 對象 */ @Autowired private UserDetailsService userDetailsService; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { // 將 ServletRequest 轉換為 HttpServletRequest 才能拿到請求頭中的 token HttpServletRequest httpRequest = (HttpServletRequest) request; // 嘗試獲取請求頭的 token String authToken = httpRequest.getHeader(this.tokenHeader); // 嘗試拿 token 中的 username // 若是沒有 token 或者拿 username 時出現異常,那么 username 為 null String username = this.tokenUtils.getUsernameFromToken(authToken); // 如果上面解析 token 成功并且拿到了 username 并且本次會話的權限還未被寫入 if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { // 用 UserDetailsService 從數據庫中拿到用戶的 UserDetails 類 // UserDetails 類是 Spring Security 用于保存用戶權限的實體類 UserDetails userDetails = this.userDetailsService.loadUserByUsername(username); // 檢查用戶帶來的 token 是否有效 // 包括 token 和 userDetails 中用戶名是否一樣, token 是否過期, token 生成時間是否在最后一次密碼修改時間之前 // 若是檢查通過 if (this.tokenUtils.validateToken(authToken, userDetails)) { // 生成通過認證 UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpRequest)); // 將權限寫入本次會話 SecurityContextHolder.getContext().setAuthentication(authentication); } if (!userDetails.isEnabled()){ response.setCharacterEncoding("UTF-8"); response.setContentType("application/json;charset=UTF-8"); response.getWriter().print("{"code":"452","data":"","message":"賬號處于黑名單"}"); return; } } chain.doFilter(request, response); } }
其中檢查 token 是否有效的 tokenUtils.validateToken(authToken, userDetails) 方法定義如下:
/** * 檢查 token 是否處于有效期內 * @param token * @param userDetails * @return */ public Boolean validateToken(String token, UserDetails userDetails) { UserDetailImpl user = (UserDetailImpl) userDetails; final String username = this.getUsernameFromToken(token); final Date created = this.getCreatedDateFromToken(token); return (username.equals(user.getUsername()) && !(this.isTokenExpired(token)) && !(this.isCreatedBeforeLastPasswordReset(created, user.getLastPasswordReset()))); } /** * 獲得我們封裝在 token 中的 token 創建時間 * @param token * @return */ public Date getCreatedDateFromToken(String token) { Date created; try { final Claims claims = this.getClaimsFromToken(token); created = new Date((Long) claims.get("created")); } catch (Exception e) { created = null; } return created; } /** * 獲得我們封裝在 token 中的 token 過期時間 * @param token * @return */ public Date getExpirationDateFromToken(String token) { Date expiration; try { final Claims claims = this.getClaimsFromToken(token); expiration = claims.getExpiration(); } catch (Exception e) { expiration = null; } return expiration; } /** * 檢查當前時間是否在封裝在 token 中的過期時間之后,若是,則判定為 token 過期 * @param token * @return */ private Boolean isTokenExpired(String token) { final Date expiration = this.getExpirationDateFromToken(token); return expiration.before(this.generateCurrentDate()); } /** * 檢查 token 是否是在最后一次修改密碼之前創建的(賬號修改密碼之后之前生成的 token 即使沒過期也判斷為無效) * @param created * @param lastPasswordReset * @return */ private Boolean isCreatedBeforeLastPasswordReset(Date created, Date lastPasswordReset) { return (lastPasswordReset != null && created.before(lastPasswordReset)); }步驟五 : 注冊步驟四的攔截器,使它在 Spring Security 讀取本次會話權限前將用戶所具有的權限寫入本次會話中
在 SpringSecurity 的配置類 WebSecurityConfig.java 中添加如下配置
public class WebSecurityConfig extends WebSecurityConfigurerAdapter { /** * 注冊 token 轉換攔截器為 bean * 如果客戶端傳來了 token ,那么通過攔截器解析 token 賦予用戶權限 * * @return * @throws Exception */ @Bean public AuthenticationTokenFilter authenticationTokenFilterBean() throws Exception { AuthenticationTokenFilter authenticationTokenFilter = new AuthenticationTokenFilter(); authenticationTokenFilter.setAuthenticationManager(authenticationManagerBean()); return authenticationTokenFilter; } @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/auth").authenticated() // 需攜帶有效 token .antMatchers("/admin").hasAuthority("admin") // 需擁有 admin 這個權限 .antMatchers("/ADMIN").hasRole("ADMIN") // 需擁有 ADMIN 這個身份 .anyRequest().permitAll() // 允許所有請求通過 .and() .csrf() .disable() // 禁用 Spring Security 自帶的跨域處理 .sessionManagement() // 定制我們自己的 session 策略 .sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 調整為讓 Spring Security 不創建和使用 session /** * 本次 json web token 權限控制的核心配置部分 * 在 Spring Security 開始判斷本次會話是否有權限時的前一瞬間 * 通過添加過濾器將 token 解析,將用戶所有的權限寫入本次 Spring Security 的會話 */ http .addFilterBefore(authenticationTokenFilterBean(), UsernamePasswordAuthenticationFilter.class); } }
其中我們將步驟四中定義的攔截器注冊到 Spring 中成為一個 bean ,并登記在 Spring Security 開始判斷本次會話是否有權限時的前一瞬間通過添加過濾器將 token 解析,將用戶所有的權限寫入本次會話。
其次,我們添加了三個 ant 風格的地址攔截規則 :
/auth : 要求攜帶有效的 token
/admin : 要求攜帶 token 所對應的賬號具有 admin 這個權限
/ADMIN : 要求攜帶 token 對應的張賬號具有 ROLE_ADMIN 這個身份
啟動程序到 8080 端口,通過 /login 接口登陸 guest 賬號,對 /auth 接口嘗試訪問,結果如下 :
顯然,因為 token 有效,所以成功通過了攔截
接下來嘗試訪問 /admin 接口,結果如下 :
顯然,因為攜帶的 token 不具有 admin 這個權限,所以請求被攔截攔截
至此,我們已經完成了一套權限簡單的權限規則系統,在下一步中,我們將對無權限訪問的返回結果進行優化,并結束這次總結。
步驟六 : 完善 401 和 403 返回結果定義 401 處理器,實現 AuthenticationEntryPoint 接口
public class EntryPointUnauthorizedHandler implements AuthenticationEntryPoint { /** * 未登錄或無權限時觸發的操作 * 返回 {"code":401,"message":"小弟弟,你沒有攜帶 token 或者 token 無效!","data":""} * @param httpServletRequest * @param httpServletResponse * @param e * @throws IOException * @throws ServletException */ @Override public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException { //返回json形式的錯誤信息 httpServletResponse.setCharacterEncoding("UTF-8"); httpServletResponse.setContentType("application/json"); httpServletResponse.getWriter().println("{"code":401,"message":"小弟弟,你沒有攜帶 token 或者 token 無效!","data":""}"); httpServletResponse.getWriter().flush(); } }
定義 403 處理器,實現 AccessDeniedHandler 接口
@Component public class MyAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException { //返回json形式的錯誤信息 httpServletResponse.setCharacterEncoding("UTF-8"); httpServletResponse.setContentType("application/json"); httpServletResponse.getWriter().println("{"code":403,"message":"小弟弟,你沒有權限訪問呀!","data":""}"); httpServletResponse.getWriter().flush(); } }
將這兩個處理器配置到 SpringSecurity 的配置類中 :
public class WebSecurityConfig extends WebSecurityConfigurerAdapter { /** * 注冊 401 處理器 */ @Autowired private EntryPointUnauthorizedHandler unauthorizedHandler; /** * 注冊 403 處理器 */ @Autowired private MyAccessDeniedHandler accessDeniedHandler; ... @Override protected void configure(HttpSecurity http) throws Exception { http ... // 配置被攔截時的處理 .exceptionHandling() .authenticationEntryPoint(this.unauthorizedHandler) // 添加 token 無效或者沒有攜帶 token 時的處理 .accessDeniedHandler(this.accessDeniedHandler) //添加無權限時的處理 ... } }
嘗試以 guest 的身份訪問 /admin 接口,結果如下:
嘻嘻,顯然任務完成啦?。。。ㄟ@個接口也可以用 lamda 表達式配置,這個留給大家去探索啦~~~)
溜了溜了……
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/67687.html
摘要:框架具有輕便,開源的優點,所以本譯見構建用戶管理微服務五使用令牌和來實現身份驗證往期譯見系列文章在賬號分享中持續連載,敬請查看在往期譯見系列的文章中,我們已經建立了業務邏輯數據訪問層和前端控制器但是忽略了對身份進行驗證。 重拾后端之Spring Boot(四):使用JWT和Spring Security保護REST API 重拾后端之Spring Boot(一):REST API的搭建...
摘要:暑假的時候在學習了并成功運用到了項目中。這是提供的一個安全權限控制框架,可以根據使用者的需要定制相關的角色身份和身份所具有的權限,完成黑名單操作攔截無權限的操作。用戶通過登陸操作獲得我們返回的并保存在本地。 暑假的時候在學習了 Spring Security 并成功運用到了項目中。 在實踐中摸索出了一套結合 json + jwt(json web token) + Spring Boo...
摘要:實現用戶認證本次,我們通過的授權機制,實現用戶授權。啟用注解默認的是不進行授權注解攔截的,添加注解以啟用注解的全局方法攔截。角色該角色對應菜單示例用戶授權代碼體現授權思路遍歷當前用戶的菜單,根據菜單中對應的角色名進行授權。 引言 上一次,使用Spring Security與Angular實現了用戶認證。Spring Security and Angular 實現用戶認證 本次,我們通過...
摘要:因為默認開啟了所有攻擊防御,需要禁用的防御。版本變化有點大,本次已成功升級了基礎依賴,及注冊中心配置中心。其他像代替了及其他組件再慢慢升級,的快速發展令升級變得非常蛋疼,本文記錄了升級過程中踩過的所有的坑。。。 Spring Boot 2.x 已經發布了很久,現在 Spring Cloud 也發布了 基于 Spring Boot 2.x 的 Finchley 版本,現在一起為項目做一次...
閱讀 1684·2021-11-23 09:51
閱讀 3174·2021-09-26 10:21
閱讀 798·2021-09-09 09:32
閱讀 881·2019-08-29 16:06
閱讀 3308·2019-08-26 13:36
閱讀 772·2019-08-26 10:56
閱讀 2564·2019-08-26 10:44
閱讀 1143·2019-08-23 14:04